diff --git a/.bumpversion.cfg b/.bumpversion.cfg index c6fbd8fa..c93b46e4 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.0 +current_version = 0.10.1 commit = True tag = True diff --git a/.dockerignore b/.dockerignore index 56153a8a..021dd2f3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,8 +2,6 @@ README.md LICENSE Procfile Procfile.windows -Pipfile.lock -Pipfile *.pyo *.pyd __pycache__ diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml deleted file mode 100644 index 36aa2465..00000000 --- a/.github/workflows/cypress.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Cypress Testing 🌲 -on: - push: - branches: - - development - pull_request: - branches: - - development -jobs: - cypress: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Build Clima - run: |- - pip install pipenv - pipenv install - - - name: Start Clima - run: |- - pipenv run python main.py & - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: '20.9.0' - - - name: Install Cypress - run: |- - cd tests/node - npm ci - - - name: Run Cypress - run: |- - cd tests/node - npm run cy:run - - - name: Archive screenshots - uses: actions/upload-artifact@v4 - if: always() - with: - name: cypress-screenshots - path: tests/node/cypress/screenshots - - - name: Archive videos - uses: actions/upload-artifact@v4 - if: always() - with: - name: cypress-videos - path: tests/node/cypress/videos - retention-days: 3 \ No newline at end of file diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 32f7e7c3..5f24de80 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -31,6 +31,19 @@ jobs: run: |- pipenv run ruff format --check . + - name: Install Playwright Browsers + run: | + pipenv run playwright install chromium + + - name: Start Clima + run: |- + pipenv run python main.py & + + - name: Wait for Clima to be ready + run: | + timeout 60 bash -c 'until curl -f http://127.0.0.1:8080; do sleep 1; done' + - name: Test Clima run: |- - pipenv run python -m pytest + cd tests + pipenv run pytest --base-url=http://127.0.0.1:8080 -vv diff --git a/Dockerfile b/Dockerfile index 03e5279c..5426972a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,19 +2,28 @@ # https://hub.docker.com/_/python FROM python:3.11-slim +# Allow statements and log messages to immediately appear in the Knative logs +ENV PYTHONUNBUFFERED=True + RUN apt-get update \ -&& apt-get install gcc -y \ -&& apt-get clean + && apt-get install --no-install-recommends -y gcc \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install pipenv +RUN python -m pip install --upgrade pip \ + && python -m pip install --no-cache-dir "pipenv>=2024.0,<2026.0" +# Set working directory +WORKDIR /app -ENV APP_HOME /app -WORKDIR $APP_HOME +# Copy Pipfile and Pipfile.lock +COPY Pipfile Pipfile.lock ./ -COPY . ./ +# Install dependencies +RUN pipenv sync -# Install production dependencies. -RUN pip install --upgrade pip -RUN pip install -r requirements.txt +COPY . . EXPOSE 8080 -CMD python main.py \ No newline at end of file +CMD ["pipenv", "run", "python", "main.py"] diff --git a/Pipfile b/Pipfile index 91288833..53d21095 100644 --- a/Pipfile +++ b/Pipfile @@ -4,12 +4,12 @@ verify_ssl = true name = "pypi" [packages] -dash = "==2.15" +dash = "==3.2" pvlib = "==0.9.1" pythermalcomfort = "==2.9.1" dash-bootstrap-components = "==1.2.0" dash-extensions = "==1.0.7" -dash-mantine-components = "==0.12.1" +dash-mantine-components = "==2.2.1" requests = "==2.32.4" plotly = "==5.18.0" pandas = "==2.2.0" @@ -24,6 +24,8 @@ bump2version = "*" black = "*" ruff = "*" pre-commit = "*" +pytest-playwright = "*" +pytest-xdist = "*" [requires] python_version = "3.11" diff --git a/Pipfile.lock b/Pipfile.lock index 26a3f806..29a098ea 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "7214a1158f64483648ecff36a57b61b67020408e0f16770b77d7165a9d6f47e0" + "sha256": "ae25ff66e89c7baec81d446be00522df942f8640983805325ba208f04732fdb5" }, "pipfile-spec": 6, "requires": { @@ -34,96 +34,130 @@ }, "certifi": { "hashes": [ - "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", - "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5" + "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", + "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43" ], "markers": "python_version >= '3.7'", - "version": "==2025.8.3" + "version": "==2025.10.5" }, "charset-normalizer": { "hashes": [ - "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", - "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", - "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", - "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", - "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", - "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", - "sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c", - "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", - "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", - "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", - "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432", - "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", - "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", - "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", - "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", - "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19", - "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", - "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e", - "sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4", - "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7", - "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312", - "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", - "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", - "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", - "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", - "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99", - "sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b", - "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", - "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", - "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", - "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", - "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", - "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", - "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc", - "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", - "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", - "sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a", - "sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40", - "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", - "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", - "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", - "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", - "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05", - "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", - "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", - "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", - "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", - "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34", - "sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9", - "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", - "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", - "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", - "sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b", - "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", - "sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942", - "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", - "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", - "sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b", - "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", - "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", - "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", - "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", - "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", - "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", - "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", - "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", - "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", - "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", - "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca", - "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", - "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", - "sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb", - "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", - "sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557", - "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", - "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7", - "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", - "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", - "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9" + "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", + "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", + "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", + "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", + "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", + "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", + "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63", + "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", + "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", + "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", + "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", + "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", + "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", + "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af", + "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", + "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", + "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", + "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", + "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", + "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", + "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576", + "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", + "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", + "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", + "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", + "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", + "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", + "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", + "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", + "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", + "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", + "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", + "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a", + "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", + "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", + "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", + "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", + "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", + "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7", + "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", + "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", + "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", + "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", + "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", + "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", + "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2", + "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", + "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", + "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", + "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", + "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", + "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", + "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", + "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", + "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa", + "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", + "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", + "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", + "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", + "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", + "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", + "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", + "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", + "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", + "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", + "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", + "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", + "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", + "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", + "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", + "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3", + "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", + "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", + "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", + "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", + "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", + "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", + "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf", + "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", + "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", + "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac", + "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", + "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", + "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", + "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", + "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", + "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", + "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4", + "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84", + "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", + "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", + "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", + "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", + "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", + "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", + "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", + "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", + "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", + "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074", + "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3", + "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", + "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", + "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", + "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d", + "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", + "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", + "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", + "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", + "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", + "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", + "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", + "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", + "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608" ], "markers": "python_version >= '3.7'", - "version": "==3.4.3" + "version": "==3.4.4" }, "click": { "hashes": [ @@ -135,12 +169,12 @@ }, "dash": { "hashes": [ - "sha256:d38891337fc855d5673f75e5346354daa063c4ff45a8a6a21f25e858fcae41c2", - "sha256:df1882bbf613e4ca4372281c8facbeb68e97d76720336b051bf84c75d2de8588" + "sha256:4c1819588d83bed2cbcf5807daa5c2380c8c85789a6935a733f018f04ad8a6a2", + "sha256:93300b9b99498f8b8ed267e61c455b4ee1282c7e4d4b518600eec87ce6ddea55" ], "index": "pypi", - "markers": "python_version >= '3.6'", - "version": "==2.15.0" + "markers": "python_version >= '3.8'", + "version": "==3.2.0" }, "dash-bootstrap-components": { "hashes": [ @@ -151,13 +185,6 @@ "markers": "python_version >= '3.6' and python_version < '4'", "version": "==1.2.0" }, - "dash-core-components": { - "hashes": [ - "sha256:52b8e8cce13b18d0802ee3acbc5e888cb1248a04968f962d63d070400af2e346", - "sha256:c6733874af975e552f95a1398a16c2ee7df14ce43fa60bb3718a3c6e0b63ffee" - ], - "version": "==2.0.0" - }, "dash-extensions": { "hashes": [ "sha256:46c4ec7c7d3b42db63032005f7d258435bc0092d0fec6f6ce000be68befeb102", @@ -167,13 +194,6 @@ "markers": "python_version >= '3.8' and python_version < '4'", "version": "==1.0.7" }, - "dash-html-components": { - "hashes": [ - "sha256:8703a601080f02619a6390998e0b3da4a5daabe97a1fd7a9cebc09d015f26e50", - "sha256:b42cc903713c9706af03b3f2548bda4be7307a7cf89b7d6eae3da872717d1b63" - ], - "version": "==2.0.0" - }, "dash-iconify": { "hashes": [ "sha256:564774be6b11b0ac3a8999b7137c3d17a1d351d69b673aa313c7228eacc9d143", @@ -184,18 +204,11 @@ }, "dash-mantine-components": { "hashes": [ - "sha256:2630bca31cb96d96fb2c4f986e639b9f92d6319aba8cba02f76da6c0d8f5ca48", - "sha256:c3dcbfd89813a1539654b8d016eb953dc5f67aafe1a77d45b5ec9faa6f25d3e7" + "sha256:a54acdb8b3e7a80251e9d68947a84b0a1d16285f5ca33363dbc29f287a13ebaa", + "sha256:cd3aefd3191be365db0435067196fe037e3803c996794c174f0f0409c24efac4" ], "index": "pypi", - "version": "==0.12.1" - }, - "dash-table": { - "hashes": [ - "sha256:18624d693d4c8ef2ddec99a6f167593437a7ea0bf153aa20f318c170c5bc7308", - "sha256:19036fa352bb1c11baf38068ec62d172f0515f73ca3276c79dee49b95ddc16c9" - ], - "version": "==5.0.0" + "version": "==2.2.1" }, "dataclass-wizard": { "hashes": [ @@ -262,18 +275,18 @@ }, "idna": { "hashes": [ - "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", - "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" + "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", + "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" ], - "markers": "python_version >= '3.6'", - "version": "==3.10" + "markers": "python_version >= '3.8'", + "version": "==3.11" }, "importlib-metadata": { "hashes": [ "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd" ], - "markers": "python_version >= '3.7'", + "markers": "python_version >= '3.9'", "version": "==8.7.0" }, "itsdangerous": { @@ -627,7 +640,7 @@ "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==1.17.0" }, "tenacity": { @@ -640,11 +653,11 @@ }, "typing-extensions": { "hashes": [ - "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", - "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76" + "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", + "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" ], "markers": "python_version >= '3.9'", - "version": "==4.14.1" + "version": "==4.15.0" }, "tzdata": { "hashes": [ @@ -664,11 +677,11 @@ }, "werkzeug": { "hashes": [ - "sha256:1bc0c2310d2fbb07b1dd1105eba2f7af72f322e1e455f2f93c993bee8c8a5f17", - "sha256:a8dd59d4de28ca70471a34cba79bed5f7ef2e036a76b3ab0835474246eb41f8d" + "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", + "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746" ], - "markers": "python_version >= '3.8'", - "version": "==3.0.6" + "markers": "python_version >= '3.9'", + "version": "==3.1.3" }, "zipp": { "hashes": [ @@ -718,6 +731,14 @@ "markers": "python_version >= '3.5'", "version": "==1.0.1" }, + "certifi": { + "hashes": [ + "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", + "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43" + ], + "markers": "python_version >= '3.7'", + "version": "==2025.10.5" + }, "cfgv": { "hashes": [ "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", @@ -726,6 +747,125 @@ "markers": "python_version >= '3.8'", "version": "==3.4.0" }, + "charset-normalizer": { + "hashes": [ + "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", + "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", + "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", + "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", + "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", + "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", + "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63", + "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", + "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", + "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", + "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", + "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", + "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", + "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af", + "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", + "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", + "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", + "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", + "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", + "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", + "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576", + "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", + "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", + "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", + "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", + "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", + "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", + "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", + "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", + "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", + "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", + "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", + "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a", + "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", + "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", + "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", + "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", + "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", + "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7", + "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", + "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", + "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", + "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", + "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", + "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", + "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2", + "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", + "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", + "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", + "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", + "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", + "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", + "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", + "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", + "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa", + "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", + "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", + "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", + "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", + "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", + "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", + "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", + "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", + "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", + "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", + "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", + "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", + "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", + "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", + "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", + "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3", + "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", + "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", + "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", + "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", + "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", + "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", + "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf", + "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", + "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", + "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac", + "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", + "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", + "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", + "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", + "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", + "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", + "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4", + "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84", + "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", + "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", + "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", + "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", + "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", + "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", + "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", + "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", + "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", + "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074", + "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3", + "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", + "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", + "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", + "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d", + "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", + "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", + "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", + "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", + "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", + "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", + "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", + "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", + "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608" + ], + "markers": "python_version >= '3.7'", + "version": "==3.4.4" + }, "cleanpy": { "hashes": [ "sha256:9ddfa7ce80dd888b597a8b0bfeea3b69567839b6f41b775a4f76f46914d5170e", @@ -750,6 +890,14 @@ ], "version": "==0.4.0" }, + "execnet": { + "hashes": [ + "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", + "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3" + ], + "markers": "python_version >= '3.8'", + "version": "==2.1.1" + }, "filelock": { "hashes": [ "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", @@ -758,6 +906,66 @@ "markers": "python_version >= '3.9'", "version": "==3.19.1" }, + "greenlet": { + "hashes": [ + "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", + "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", + "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", + "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", + "sha256:16458c245a38991aa19676900d48bd1a6f2ce3e16595051a4db9d012154e8433", + "sha256:18d9260df2b5fbf41ae5139e1be4e796d99655f023a636cd0e11e6406cca7d58", + "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", + "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", + "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", + "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", + "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", + "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", + "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d", + "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", + "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", + "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", + "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", + "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", + "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633", + "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", + "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", + "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31", + "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", + "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b", + "sha256:671df96c1f23c4a0d4077a325483c1503c96a1b7d9db26592ae770daa41233d4", + "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", + "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c", + "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98", + "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f", + "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c", + "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590", + "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", + "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", + "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", + "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", + "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", + "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", + "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", + "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c", + "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594", + "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", + "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", + "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", + "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", + "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b", + "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df", + "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", + "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", + "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb", + "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", + "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", + "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", + "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c", + "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968" + ], + "markers": "python_version >= '3.9'", + "version": "==3.2.4" + }, "identify": { "hashes": [ "sha256:60381139b3ae39447482ecc406944190f690d4a2997f2584062089848361b33b", @@ -766,13 +974,21 @@ "markers": "python_version >= '3.9'", "version": "==2.6.13" }, - "iniconfig": { + "idna": { "hashes": [ - "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", - "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760" + "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", + "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" ], "markers": "python_version >= '3.8'", - "version": "==2.1.0" + "version": "==3.11" + }, + "iniconfig": { + "hashes": [ + "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", + "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" + ], + "markers": "python_version >= '3.10'", + "version": "==2.3.0" }, "mypy-extensions": { "hashes": [ @@ -814,6 +1030,20 @@ "markers": "python_version >= '3.9'", "version": "==4.3.8" }, + "playwright": { + "hashes": [ + "sha256:012dc89ccdcbd774cdde8aeee14c08e0dd52ddb9135bf10e9db040527386bd76", + "sha256:25b0d6b3fd991c315cca33c802cf617d52980108ab8431e3e1d37b5de755c10e", + "sha256:29a0777c4ce1273acf90c87e4ae2fe0130182100d99bcd2ae5bf486093044838", + "sha256:29e6d1558ad9d5b5c19cbec0a72f6a2e35e6353cd9f262e22148685b86759f90", + "sha256:7eb5956473ca1951abb51537e6a0da55257bb2e25fc37c2b75af094a5c93736c", + "sha256:8290cf27a5d542e2682ac274da423941f879d07b001f6575a5a3a257b1d4ba1c", + "sha256:c6d4d8f6f8c66c483b0835569c7f0caa03230820af8e500c181c93509c92d831", + "sha256:d7da108a95001e412effca4f7610de79da1637ccdf670b1ae3fdc08b9694c034" + ], + "markers": "python_version >= '3.9'", + "version": "==1.55.0" + }, "pluggy": { "hashes": [ "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", @@ -831,6 +1061,14 @@ "markers": "python_version >= '3.9'", "version": "==4.3.0" }, + "pyee": { + "hashes": [ + "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", + "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37" + ], + "markers": "python_version >= '3.8'", + "version": "==13.0.0" + }, "pygments": { "hashes": [ "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", @@ -841,12 +1079,46 @@ }, "pytest": { "hashes": [ - "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", - "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c" + "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", + "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==8.4.2" + }, + "pytest-base-url": { + "hashes": [ + "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", + "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6" + ], + "markers": "python_version >= '3.8'", + "version": "==2.1.0" + }, + "pytest-playwright": { + "hashes": [ + "sha256:94b551b2677ecdc16284fcd6a4f0045eafda47a60e74410f3fe4d8260e12cabf", + "sha256:fcc46510fb75f8eba6df3bc8e84e4e902483d92be98075f20b9d160651a36d90" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==0.7.1" + }, + "pytest-xdist": { + "hashes": [ + "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", + "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==8.4.1" + "version": "==3.8.0" + }, + "python-slugify": { + "hashes": [ + "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", + "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856" + ], + "markers": "python_version >= '3.7'", + "version": "==8.0.4" }, "pyyaml": { "hashes": [ @@ -907,6 +1179,14 @@ "markers": "python_version >= '3.8'", "version": "==6.0.2" }, + "requests": { + "hashes": [ + "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", + "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422" + ], + "markers": "python_version >= '3.8'", + "version": "==2.32.4" + }, "ruff": { "hashes": [ "sha256:07adb221c54b6bba24387911e5734357f042e5669fa5718920ee728aba3cbadc", @@ -933,6 +1213,29 @@ "markers": "python_version >= '3.7'", "version": "==0.12.9" }, + "text-unidecode": { + "hashes": [ + "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", + "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93" + ], + "version": "==1.3" + }, + "typing-extensions": { + "hashes": [ + "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", + "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" + ], + "markers": "python_version >= '3.9'", + "version": "==4.15.0" + }, + "urllib3": { + "hashes": [ + "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", + "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc" + ], + "markers": "python_version >= '3.9'", + "version": "==2.5.0" + }, "virtualenv": { "hashes": [ "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", @@ -942,4 +1245,4 @@ "version": "==20.34.0" } } -} +} \ No newline at end of file diff --git a/assets/banner.css b/assets/banner.css deleted file mode 100644 index edba1bce..00000000 --- a/assets/banner.css +++ /dev/null @@ -1,19 +0,0 @@ -/**Banner**/ -#banner { - padding: 1rem; - background-color: #003262; - color: white; - /* position: sticky; */ - z-index: 1; -} - -#banner-title { - font-family: 'Open Sans', sans-serif; - font-weight: 500; -} - -#banner-subtitle { - font-family: 'Poppins', sans-serif; - font-weight: 400; - max-height: 25px; -} \ No newline at end of file diff --git a/assets/construction.css b/assets/construction.css deleted file mode 100644 index 930d1086..00000000 --- a/assets/construction.css +++ /dev/null @@ -1,5 +0,0 @@ -#construction-container { - height: 80vh; - align-items: center; - margin-top: 20px; -} \ No newline at end of file diff --git a/assets/fonts.css b/assets/fonts.css deleted file mode 100644 index 61d71fb3..00000000 --- a/assets/fonts.css +++ /dev/null @@ -1,5 +0,0 @@ -@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600&display=swap'); - -@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@300;400&display=swap'); - -@import url('https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300;0,400;0,600;0,700;0,800;1,300;1,400;1,600;1,700;1,800&display=swap'); \ No newline at end of file diff --git a/assets/footer.css b/assets/footer.css deleted file mode 100644 index b334dcd4..00000000 --- a/assets/footer.css +++ /dev/null @@ -1,18 +0,0 @@ -#footer-container { - padding: 1rem; - margin:0; - color: white; - background-color: #003262; -} - -a { - color: white; - text-decoration: underline; -} - -a:hover { - color: lightgrey; -} - - - diff --git a/assets/layout.css b/assets/layout.css deleted file mode 100644 index e9089dbc..00000000 --- a/assets/layout.css +++ /dev/null @@ -1,32 +0,0 @@ -.container-col { - display: flex; - flex-direction: column; -} - -.container-row { - display: flex; - flex-direction: row; -} - -.justify-center { - justify-content: center; -} - -.align-center{ - align-items: center; -} - -.container-stretch { - align-items: stretch; -} - -.full-width { - width: 100%; -} - -.doc-modal-body img { - box-sizing: border-box; - width: 100%; //or any percentage width you want -} - - diff --git a/assets/manifest.json b/assets/manifest.json index 591f5946..b4cb40c7 100644 --- a/assets/manifest.json +++ b/assets/manifest.json @@ -467,7 +467,7 @@ "orientation": "portrait", "background_color": "#ffffff", "display": "standalone", - "id": "0.9.0", + "id": "0.10.1", "description": "CBE Clima Tool: a free and open-source web application for climate analysis tailored to sustainable building design", "start_url": "/", "scope": "/", diff --git a/assets/tabs.css b/assets/tabs.css deleted file mode 100644 index 15590dff..00000000 --- a/assets/tabs.css +++ /dev/null @@ -1,288 +0,0 @@ -*[data-dash-is-loading="true"]{ - visibility: hidden; -} -*[data-dash-is-loading="true"]::before{ - content: ""; - display: inline-block; - color: magenta; - visibility: visible; - height: 10rem -} - -/* Tabs */ -#tabs { - margin: 0; -} - -#tabs-content { - padding: 1rem; -} - -#tabs-parent { - padding: 0 1rem; - background-color: #003262 !important; -} - -#store-container { - padding: 0; -} - -#loading-container { - top: 0; -} - -.custom-tabs-container { - width: 85%; -} - -.custom-tabs { - background-color: #f9f9f9; - padding: 0 24px; - border-bottom: 1px solid #d6d6d6; -} - -.custom-tab { - border-color: rgb(238, 236, 236); - border-top-left-radius: 3px; - border-top-right-radius: 3px; - border-top: 3px solid transparent !important; - border-left: 1px solid lightgrey !important; - border-right: 1px solid lightgrey !important; - border-bottom: 1px solid #d6d6d6; - background-color: #f6f8f8; - padding: 12px !important; - font-family: "system-ui"; - display: flex !important; - align-items: center; - justify-content: center; -} - -.custom-tab:has(.active) { - color:#586069; - background-color: white; - box-shadow: 1px 1px 0 white; - border-left: 1px solid lightgrey !important; - border-right: 1px solid lightgrey !important; - border-top: 6px solid #abd2ff !important; - border-bottom: 1px solid transparent; -} - -.nav-pills { - display: flex; - flex-wrap: wrap; -} - -@media (max-width: 900px) { - .nav-pills { - flex-direction: column; - } -} - -.nav-pills .nav-link { - padding: 0; - color: #586069; - font-family: "system-ui"; - background-color: transparent; -} - -.nav-pills .nav-link.disabled { - color: #c8c6c6; -} - -.nav-pills .nav-link.active { - color: black; - background-color: white; -} - -/* Tab One */ -#tab-one-container { - height: 100%; - padding-top: 0; -} - -#tab-one-form-container { - justify-content: space-between; -} - -#store-container { - height: 100%; -} - -#tab-one-map { - margin-top: 28px; - /*height: 100vh;*/ - width: 100%; -} - -#map-credits { - padding-top: 10px; -} - - -/* Tab Two */ -#tab-two-container { - justify-content: space-between; - width: 100%; -} - -p { - margin-bottom: 0; -} - -#world-map { - height: 65%; - width: 100%; -} - -.violin-container { - height: 100%; - width: 100%; - display: flex; - align-items: stretch; -} - -.loading-violin-container { - width: 100%; - height: 100%; -} - -#temp-profile-graph, #humidity-profile-graph, #solar-radiation-graph, #wind-speed-graph { - height: 50%; - /*width: 100%;*/ -} - -/* Tab Temperature RH*/ -.dropdown-t-rh { - width: 15rem; -} - -/* Tab Four */ -#tab-four-container { - width: 100%; - justify-content: center; - align-items: center; -} - -/*#tab-four-custom-sun-container {*/ -/* justify-content: space-evenly;*/ -/* align-items: stretch;*/ -/* align-content: center;*/ -/*}*/ - -/* Tab Five */ -#tab-five-container { - justify-content: center; - align-items: stretch; -} - -#tab5-daily-container { - margin-top: 60px; -} - -#slider-container { - width: 50%; - margin: 24px; -} - -.seasonal-graph { - width: 100%; - height: 100%; -} - -.daily-wind-graph { - width: 80%; - height: 80%; -} - -#daily-wind-rose-container { - display: flex; - flex-direction: row; - max-width: 100%; -} - -#daily-wind-rose-outer-container { - align-items: stretch; -} - -#slider-container * { - justify-content: center; -} - -.each-slider { - padding: 32px 0 0 0; - width: 60%; -} - -#hour-slider, #month-slider { - width: 60%; -} - -#wind-speed, #wind-direction { - width: 100%; -} - -#custom-windrose-container { - margin-top: 80px; -} - -#tab5-custom-dropdown-container { - margin-top: 30px; -} - -/* Tab Six */ - -#first-var-dropdown { - width: 15%; -} - -.month-hour-slider { - width: 25%; -} - -.var-dropdown { - width: 35%; -} - -#min-val, #max-val { - width: 15%; -} - -.row-center { - align-items: center; - justify-content: center; -} - -.text-next-to-input { - margin-bottom: 0; - margin-right: 1rem; - text-align: right; -} - -#sec1-var-dropdown { - width: 25%; -} - -.three-inputs-container { - justify-content: space-evenly; -} - -.one-of-three-container { - width: 30%; - align-self: flex-start; - align-items: stretch; -} - -#tab6-sec2-container { - align-items: stretch; -} - -.survey-alert { - color: white; - background-color: #0c2772; - opacity: 0.98; - font-family: "system-ui"; - font-size: 15px; - border-radius: 0.25rem; - border: 0.5px solid lightgrey; - z-index: 1000; -} \ No newline at end of file diff --git a/config.py b/config.py index bc794545..b53ce106 100644 --- a/config.py +++ b/config.py @@ -54,7 +54,7 @@ class Assets: class PageInfo: """Stores page names and orders for registration.""" - SELECT_NAME = "Select Weather File" + SELECT_NAME = "Select weather file" SELECT_ORDER = 0 SUMMARY_NAME = "Climate Summary" SUMMARY_ORDER = 1 diff --git a/docs/README.md b/docs/README.md index 05ce6e22..b70bb2de 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,12 @@ This ongoing project results from the collaboration and contributions of the peo * [Chun Him Lee](https://www.linkedin.com/in/chun-him-lee-01b553129/): Coding and review * [Tu Minh Phuong Doan](https://www.linkedin.com/in/harry-doan-legopher/): Coding and review * [Yixun Quan](https://www.linkedin.com/in/yixun-quan-929a661a3): Coding and review +* [Yuqing Luo](https://leo-5a2.pages.dev/): Coding, code maintenance and review +* Wenshu lyu: Coding, code maintenance and review +* Ziqi Liu: Coding, code maintenance and review +* Tianchi Liu: Coding, code maintenance and review +* [Qian Liu](https://www.linkedin.com/in/qian-liu-48b294342): Coding, code maintenance and review +* Feng Wang: Coding, code maintenance and review ## Acknowledgment diff --git a/docs/contributing/contributing.md b/docs/contributing/contributing.md index 48aa2860..f0e21153 100644 --- a/docs/contributing/contributing.md +++ b/docs/contributing/contributing.md @@ -5,17 +5,85 @@ description: Guide on how to contribute to this project # How to contribute First off, thanks for taking the time to contribute! +We use GitHub as our main collaboration platform. Please work from the `development` branch, create small feature branches, and open focused pull requests. Follow Conventional Commit messages (e.g., `feat:`, `fix:`, `docs:`), format Python code with Black, and add tests where needed. Never merge your own PR—wait for review and address all comments (including AI reviewer suggestions). Use Issues and Projects to track tasks and discussions. + +> This project requires Python 3.11. Do not use Python 3.12 or newer, as it may cause dependency incompatibilities, build failure or runtime errors + ## General Feedback If you have a general feedback about our project, please do not open an issue but instead please fill in this [form](https://forms.gle/LRUq3vsFnE1QCLiA6) +## Fork & branch processing + +First fork the origin repository to your own github repository, then clone the repository to your local computer. + +```bash +git clone https://github.com//clima.git +cd clima +``` + +Install the dependencies using pipenv. You will need to have pipenv installed on your machine. If you do not have it yet, please refer to [pipenv installation guide](https://pipenv.pypa.io/en/latest/#install-pipenv-today). + +```bash +pipenv sync --dev +```` + +Set up the upstream repository and check the output repositories. + +```bash +git remote add upstream https://github.com/CenterForTheBuiltEnvironment/clima.git + +git remote -v +``` + +The terminal should output a list: + +- `origin → your Fork repository` +- `upstream → origin repository` + +Check all branches. + +```bash +git branch -a +``` + +The terminal will show a list of branches: + +```bash +> * main + remotes/origin/HEAD -> origin/main + remotes/origin/development + remotes/origin/main +``` + +Pull the development branch first, and if the terminal does not notice you that you should try the second command. + +```bash +git checkout development + +git checkout -b development origin/development +``` + +Create a new branch in the development branch. + +```bash +git checkout -b +``` + +Finally update and push to your repository branch if you modify the files. + +```bash +git push origin +``` + ## Code of Conduct Available [here](code_of_conduct.md) ## Code style +### Code Formatting We use ruff to enforce the code style and code formatting. You can run it with: ```bash @@ -35,6 +103,39 @@ pipenv run pre-commit run --all-files Hence, you will need to make sure that the code is formatted correctly before committing your changes; otherwise, the commit will fail. More information about pre-commit hooks can be found [here](https://pre-commit.com/). +### Code Simplicity +Strive to minimize redundancy in your code. +- Keep logic as concise as possible. +- Remove unused variables, imports, and components. +- Prefer reusable utilities to repeated patterns. + +### UI Modifications +For UI-related changes: +* Use [DMC (Dash Mantine Components)](https://www.dash-mantine-components.com/) wherever applicable for layout and styling consistency. +* Do not use CSS, only inline styles are allowed, but still try to minimize their use. +* Ensure visual consistency with existing components. + +## Testing + +Before submitting a Pull Request, please make sure: +- All tests should pass. +- You have installed project dependencies: + +```bash +pipenv sync --dev +pipenv run playwright install # Required to install browsers +``` +Start the app server: +```bash +pipenv run python main.py +``` +Then, from the root directory, run tests using pytest: +```bash +cd tests + +pipenv run pytest --base-url=http://127.0.0.1:8080 -vv -n 2 +``` + ## Submitting changes Please send a Pull Request with a clear list of what you've done. Always write a clear log message for your commits. One-line messages are fine for small changes, but bigger changes should look like this: @@ -45,6 +146,51 @@ $ git commit -m "A brief summary of the commit > A paragraph describing what changed and its impact." ``` +> Detailed requirements for submitting a PR are described in the [Pull Request Regulation](#pull-request-regulation) section below + +Classification of Common Commit Types: + +- `Main (Master)`: Stable branch, merge code that passes review and CI; merge and release every time, +- `Develop`: Continuous Integration branch for daily integration with multiple collaborators. +- `Feature/*`: feature development branch, cut out from main or develop, send PR to merge in after completing the feature. +- `Fix/*`: defect repair branch, the same process as feature +- `Release/*`: release preparation branch for freezing versions, fixing documentation, doing regressions and tagging. +- `docs/*`, `chore/*`, `refactor/*`, `test/*`: documentation, miscellaneous, refactor, test type branches. +- `Style`: style modification (does not affect the function): code formatting, space adjustment, naming rules unity. +- `Refactor`: Code Refactoring: Refactor existing code to improve maintainability. +- `Test`: Add or modify tests: add unit tests, integration tests, or modify test logic. +- `Chore`: Build Configuration, Dependency Management, CI/CD Configuration Updates. +- `Perf`: Performance Optimisation: Optimising code execution efficiency or memory usage. +- `Ci`: CI Configuration Related: Changing Continuous Integration Configurations for Github Actions, Travis, Jenkins, etc. +- `Build`: build system related: modify build scripts, packaging configuration. +- `Revert`: Rollback Commit: Undoing a Previous Commit +- `Security`: Security fixes, fixing security vulnerabilities, updating dependencies to prevent attacks. +- `Deps`: Dependency Management: Dependency Management/Adding, updating, and removing dependency libraries +- `Infra`: Infrastructure related: changes to development environments, containers, server configurations, etc. + +## Pull Request Regulation +**Time to submit PR:** + +- User requirements/issues have been addressed or discussed in Issue and consensus has been reached. +- Changes have been minimised (small steps/phased submission) to avoid "mega PRs". + +**The pull request should include the following information:** + +- **Description:** Provide a brief summary of the changes, related issues, and motivation. List any required dependencies. **Fixes # (issue)** + +- **Type of Change:** Bug fix (non-breaking); New feature (non-breaking); Breaking change; Documentation update. + +- **Testing:** Describe how you tested your changes and how we can reproduce them. Include test details if necessary. + +**Pull Request Review:** + +- After submitting a Pull Request (PR), please @Coderabbit for review. +- Check all improvement suggestions provided by Coderabbit before requesting a final review. + +**Discussion of Solutions:** + When needed, seek feedback from collaborators Toby and Giobetti before making major design or logic decisions. + + ## Thanks Thank you again for being interested in this project! You are awesome! diff --git a/docs/contributing/run-project-locally.md b/docs/contributing/run-project-locally.md index e125cd23..011e50a7 100644 --- a/docs/contributing/run-project-locally.md +++ b/docs/contributing/run-project-locally.md @@ -90,19 +90,41 @@ First make sure you that: * that you are logged in with the right account * you have updated the Pipfile.lock. -```text +First test the application. + +```bash +cd tests +pipenv run pytest --base-url=http://127.0.0.1:8080 -vv --numprocesses 4 +``` + +### Deploy test version manually using gcloud and docker locally - quicker and great to test small changes + +```bash gcloud components update --quiet -pipenv requirements > requirements.txt +gcloud auth login # or gcloud config set account ACCOUNT +gcloud config set project clima-316917 +gcloud auth configure-docker us-central1-docker.pkg.dev --quiet +docker buildx build --platform linux/amd64 -t us-central1-docker.pkg.dev/clima-316917/cloud-run-source-deploy/clima:latest . + +# run locally to test +docker run --platform linux/amd64 --rm -p 8080:8080 us-central1-docker.pkg.dev/clima-316917/cloud-run-source-deploy/clima:latest +# Run detached, give a name, set env vars, and print logs +docker run -d --platform linux/amd64 --name clima-test -p 8080:8080 -e ENV=dev us-central1-docker.pkg.dev/clima-316917/cloud-run-source-deploy/clima:latest +docker logs -f clima-test + +# if everything is ok push to google container registry +docker push us-central1-docker.pkg.dev/clima-316917/cloud-run-source-deploy/clima:latest +gcloud run deploy clima-test --image us-central1-docker.pkg.dev/clima-316917/cloud-run-source-deploy/clima:latest --region us-central1 --memory 4Gi --cpu 2 --platform managed --allow-unauthenticated ``` ### Deploy test version of the project -```text +```bash gcloud builds submit --project=clima-316917 --substitutions=_REPO_NAME="clima-test",_PROJ_NAME="clima-316917",_IMG_NAME="test",_GCR="us.gcr.io",_REGION="us-central1",_MEMORY="4Gi",_CPU="2" ``` ### Deploy main version of the project -```text +```bash gcloud builds submit --project=clima-316917 --substitutions=_REPO_NAME="clima",_PROJ_NAME="clima-316917",_IMG_NAME="main",_GCR="us.gcr.io",_REGION="us-central1",_MEMORY="4Gi",_CPU="2" ``` \ No newline at end of file diff --git a/docs/documentation/tabs-explained/outdoor-comfort/utci-explained.md b/docs/documentation/tabs-explained/outdoor-comfort/utci-explained.md index 3e1b5dc8..88dacb9f 100644 --- a/docs/documentation/tabs-explained/outdoor-comfort/utci-explained.md +++ b/docs/documentation/tabs-explained/outdoor-comfort/utci-explained.md @@ -18,3 +18,4 @@ The values are then converted into a scale assessing thermal stress, either beca

UTCI heat stress index heatmap in the four conditions for Rome, ITA

The UTCI is a useful tool to design the outdoor space, to maximize the number of comfortable hours. The designer can influence two factors out of the four driving outdoor comfort: radiant temperature (i.e. exposure to the sun) and wind speed (i.e. exposure to the wind). + diff --git a/docs/documentation/tabs-explained/psychrometric-chart/README.md b/docs/documentation/tabs-explained/psychrometric-chart/README.md index 13041107..a0817de6 100644 --- a/docs/documentation/tabs-explained/psychrometric-chart/README.md +++ b/docs/documentation/tabs-explained/psychrometric-chart/README.md @@ -22,5 +22,4 @@ Moreover, data can be filtered by date, time, or one of the [Clima dataframe](.. Learn more about the Psychrometric tab by watching the following video. -{% embed url="https://youtu.be/VJ_wOHadVdw?si=iAcBQpq3IgCNY-H6&t=582" %} - +{% embed url="https://youtu.be/VJ_wOHadVdw?si=iAcBQpq3IgCNY-H6&t=582" %} \ No newline at end of file diff --git a/docs/documentation/tabs-explained/psychrometric-chart/psychrometric-chart-explained.md b/docs/documentation/tabs-explained/psychrometric-chart/psychrometric-chart-explained.md index 08ba2630..28a3b285 100644 --- a/docs/documentation/tabs-explained/psychrometric-chart/psychrometric-chart-explained.md +++ b/docs/documentation/tabs-explained/psychrometric-chart/psychrometric-chart-explained.md @@ -28,4 +28,4 @@ The main application of the psychrometric diagram is in the design of large all- The diagram is applied whenever the humidity of a particular environment needs to be studied, for reasons of thermal comfort or for the preservation of valuable objects, such as in museums. -

Stradivari Violin, stored under precise temperature and humidity conditions to prevent the valuable wood from warping. Source: Frammentirivista

+

Stradivari Violin, stored under precise temperature and humidity conditions to prevent the valuable wood from warping. Source: Frammentirivista

\ No newline at end of file diff --git a/docs/documentation/tabs-explained/sun-and-cloud/README.md b/docs/documentation/tabs-explained/sun-and-cloud/README.md index 9f354fd7..29cbb1da 100644 --- a/docs/documentation/tabs-explained/sun-and-cloud/README.md +++ b/docs/documentation/tabs-explained/sun-and-cloud/README.md @@ -26,4 +26,3 @@ This allows the user to identify climatic patterns in relation to the apparent s Learn more about the Sun and Clouds tab by watching the following video. {% embed url="https://youtu.be/VJ_wOHadVdw?si=mB2xNH57MWW_4CRR&t=447" %} - diff --git a/docs/documentation/tabs-explained/sun-and-cloud/cloud-coverage.md b/docs/documentation/tabs-explained/sun-and-cloud/cloud-coverage.md index 25755eca..bc250e1a 100644 --- a/docs/documentation/tabs-explained/sun-and-cloud/cloud-coverage.md +++ b/docs/documentation/tabs-explained/sun-and-cloud/cloud-coverage.md @@ -18,4 +18,4 @@ As the Cloud cover is reported in tenths of coverage (i.e. 0 is 0/10 covered. 10 | Cloudy (ABOVE range) | | 9 | | Cloudy (ABOVE range) | | 10 | -

Example cloud coverage graph for San Francisco, USA

+

Example cloud coverage graph for San Francisco, USA

\ No newline at end of file diff --git a/docs/documentation/tabs-explained/sun-and-cloud/customizable-daily-and-hourly-maps.md b/docs/documentation/tabs-explained/sun-and-cloud/customizable-daily-and-hourly-maps.md index 34481ad7..e226ac93 100644 --- a/docs/documentation/tabs-explained/sun-and-cloud/customizable-daily-and-hourly-maps.md +++ b/docs/documentation/tabs-explained/sun-and-cloud/customizable-daily-and-hourly-maps.md @@ -11,4 +11,3 @@ The chart above shows the [scatter plot](https://en.wikipedia.org/wiki/Scatter\_ [Heat maps](https://en.wikipedia.org/wiki/Heat\_map) allow the intensity of values to be perceived through color palettes. These graphs are very helpful in seeing how magnitudes vary throughout the year.

Examples of daily graphs with different variables (from top left to bottom right): global horizontal radiation, global horizontal illuminance, zenith luminance, opaque sky cover

- diff --git a/docs/documentation/tabs-explained/sun-and-cloud/global-and-diffuse-horizontal-solar-radiation/README.md b/docs/documentation/tabs-explained/sun-and-cloud/global-and-diffuse-horizontal-solar-radiation/README.md index fcb336eb..1103a96c 100644 --- a/docs/documentation/tabs-explained/sun-and-cloud/global-and-diffuse-horizontal-solar-radiation/README.md +++ b/docs/documentation/tabs-explained/sun-and-cloud/global-and-diffuse-horizontal-solar-radiation/README.md @@ -12,3 +12,4 @@ Typical daily graphs showing the amount of energy gained from the sun have many * evaluating sustainable **renewable energy solutions** such as solar thermal or photovoltaic panels. The integral of the curves in the graphs is the total energy (in Wh/m²), supplied by the sun. Be careful in considering the [different types of solar radiation.](global-diffuse-and-normal-solar-radiation-explained.md) + diff --git a/docs/documentation/tabs-explained/sun-and-cloud/global-and-diffuse-horizontal-solar-radiation/global-diffuse-and-normal-solar-radiation-explained.md b/docs/documentation/tabs-explained/sun-and-cloud/global-and-diffuse-horizontal-solar-radiation/global-diffuse-and-normal-solar-radiation-explained.md index f93f94de..fd87f855 100644 --- a/docs/documentation/tabs-explained/sun-and-cloud/global-and-diffuse-horizontal-solar-radiation/global-diffuse-and-normal-solar-radiation-explained.md +++ b/docs/documentation/tabs-explained/sun-and-cloud/global-and-diffuse-horizontal-solar-radiation/global-diffuse-and-normal-solar-radiation-explained.md @@ -13,4 +13,4 @@ $$ ![Conceptual representation of Global Horizontal, Diffuse Horizontal and Direct Normal Solar Radiation](../../../../.gitbook/assets/picture3.png) -![Measurement of Direct Irradiation on a horizontal and a normal plane](../../../../.gitbook/assets/picture4.png) +![Measurement of Direct Irradiation on a horizontal and a normal plane](../../../../.gitbook/assets/picture4.png) \ No newline at end of file diff --git a/docs/documentation/tabs-explained/tab-summary/README.md b/docs/documentation/tabs-explained/tab-summary/README.md index 06a3b3fd..8024c92e 100644 --- a/docs/documentation/tabs-explained/tab-summary/README.md +++ b/docs/documentation/tabs-explained/tab-summary/README.md @@ -14,4 +14,3 @@ Learn more about the Climate Summary tab by watching the following video. {% embed url="https://youtu.be/VJ_wOHadVdw?si=H-93XRhh5Neuby_b&t=220" %} - diff --git a/docs/documentation/tabs-explained/tab-summary/clima-dataframe.md b/docs/documentation/tabs-explained/tab-summary/clima-dataframe.md index 2f985e61..0806f435 100644 --- a/docs/documentation/tabs-explained/tab-summary/clima-dataframe.md +++ b/docs/documentation/tabs-explained/tab-summary/clima-dataframe.md @@ -29,4 +29,4 @@ All the variables in the new Clima dataframe are listed below. * [Wet-bulb temperature](https://en.wikipedia.org/wiki/Wet-bulb\_temperature) * [Elevation](https://en.wikipedia.org/wiki/Solar\_zenith\_angle) * [Azimuth](https://en.wikipedia.org/wiki/Solar\_azimuth\_angle) -* [Saturation pressure](https://en.wikipedia.org/wiki/Vapour\_pressure\_of\_water) +* [Saturation pressure](https://en.wikipedia.org/wiki/Vapour\_pressure\_of\_water) \ No newline at end of file diff --git a/docs/documentation/tabs-explained/tab-summary/climate-profiles-explained.md b/docs/documentation/tabs-explained/tab-summary/climate-profiles-explained.md index 1334a7dd..51756f30 100644 --- a/docs/documentation/tabs-explained/tab-summary/climate-profiles-explained.md +++ b/docs/documentation/tabs-explained/tab-summary/climate-profiles-explained.md @@ -15,4 +15,4 @@ On mouse hover, they display various statistical properties of the data: * 1st quartile * 3rd quartile -![Climate Profiles for Jerusalem Center, ISRAEL](<../../../.gitbook/assets/image (2) (1) (1).png>) +![Climate Profiles for Jerusalem Center, ISRAEL](<../../../.gitbook/assets/image (2) (1) (1).png>) \ No newline at end of file diff --git a/docs/documentation/tabs-explained/tab-summary/degree-days-explained.md b/docs/documentation/tabs-explained/tab-summary/degree-days-explained.md index 115d98d0..0d6d9d09 100644 --- a/docs/documentation/tabs-explained/tab-summary/degree-days-explained.md +++ b/docs/documentation/tabs-explained/tab-summary/degree-days-explained.md @@ -12,4 +12,4 @@ The base temperature does not necessarily correspond to the desired building int ![example deegree days for New York, Downtown Manhattan, NY, USA](<../../../.gitbook/assets/image (3).png>) -![example deegree days for Palermo Boccadifalco Airport, ITALY](<../../../.gitbook/assets/image (1) (1).png>) +![example deegree days for Palermo Boccadifalco Airport, ITALY](<../../../.gitbook/assets/image (1) (1).png>) \ No newline at end of file diff --git a/docs/version/changelog.md b/docs/version/changelog.md index a40480ca..9ba38ebc 100644 --- a/docs/version/changelog.md +++ b/docs/version/changelog.md @@ -1,5 +1,19 @@ # Changelog +## Version 0.9.0\(2025-10-30) + +Feat: + +* Moved the top navigation bar to the left side +* Extracted the hours and months into a global filter and place it in the Sidebar +* Migrated testing framework from Cypress to Playwright + +Fix: + +* Fixed issues #249, #245, #159, #242: Code redundancy and CSS styling issues +* Fixed issues #248 +* Fixed issues #236, #259: Sidebar issues + ## Version 0.8.5 \(2023-04-12\) Feat: diff --git a/main.py b/main.py index d6fccbfa..cdf1b941 100644 --- a/main.py +++ b/main.py @@ -1,34 +1,23 @@ -import dash_bootstrap_components as dbc -from dash import html, dcc -from dash_extensions.enrich import Output, Input, callback +from dash import dcc +import dash_mantine_components as dmc from app import app -from pages.lib.layout import banner, footer, build_tabs +from pages.lib.layout import create_collapsible_layout from config import AppConfig +from pages.lib.global_element_ids import ElementIds server = app.server app.title = AppConfig.TITLE -app.layout = dbc.Container( - fluid=True, - style={"padding": "0"}, +app.layout = dmc.MantineProvider( children=[ - dcc.Location(id="url", refresh=False), # connected to callback below - banner(), - html.Div(id="page-content", children=build_tabs()), - footer(), + dcc.Location(id=ElementIds.MAIN_URL, refresh=False), + create_collapsible_layout(), ], ) - -# callback for survey alert (dbc.Toast) -@callback(Output("alert-auto", "is_open"), Input("interval-component", "n_intervals")) -def display_alert(n): - return n == 1 - - if __name__ == "__main__": - app.run_server( + app.run( debug=AppConfig.DEBUG, host=AppConfig.HOST, port=AppConfig.PORT, diff --git a/pages/explorer.py b/pages/explorer.py index f2c899bd..961f4933 100644 --- a/pages/explorer.py +++ b/pages/explorer.py @@ -1,6 +1,6 @@ import dash -from dash import dcc, html -import dash_bootstrap_components as dbc +from dash import dcc +import dash_mantine_components as dmc from dash_extensions.enrich import Output, Input, State, callback from dash.exceptions import PreventUpdate @@ -12,14 +12,16 @@ two_var_graph, three_var_graph, ) +from pages.lib.global_element_ids import ElementIds +from pages.lib.global_variables import Variables +from pages.lib.global_id_buttons import IdButtons +from pages.lib.global_tab_names import TabNames from pages.lib.global_scheme import ( fig_config, dropdown_names, sun_cloud_tab_dropdown_names, more_variables_dropdown, sun_cloud_tab_explore_dropdown_names, - container_row_center_full, - container_col_center_one_of_three, ) from pages.lib.template_graphs import ( heatmap, @@ -58,14 +60,23 @@ explore_dropdown_names.pop("None", None) +def layout(): + """Return the contents of tab six.""" + return dmc.Stack( + p="md", + children=[*section_one(), section_two(), section_three()], + ) + + def section_one_inputs(): """Return the inputs from section one.""" - return html.Div( - className="container-row full-width row-center", + return dmc.Group( + mt="md", + justify="center", children=[ - html.H4(className="text-next-to-input", children=["Select a variable: "]), + dmc.Title("Select a variable:", order=5), dropdown( - id="sec1-var-dropdown", + id=ElementIds.SEC1_VAR_DROPDOWN, options=explore_dropdown_names, value="DBT", ), @@ -75,302 +86,110 @@ def section_one_inputs(): def section_one(): """Return the graphs for section one""" - return html.Div( - className="container-col full-width", - children=[ - section_one_inputs(), - html.Div( - children=title_with_link( - text="Yearly chart", - id_button="explore-yearly-chart-label", - doc_link=DocLinks.TEMP_HUMIDITY_EXPLAINED, - ), - ), - dcc.Loading( - type="circle", - children=html.Div(id="yearly-explore", className="full-width"), - ), - html.Div( - children=title_with_link( - text="Daily chart", - id_button="explore-daily-chart-label", - doc_link=DocLinks.TEMP_HUMIDITY_EXPLAINED, - ), - ), - dcc.Loading( - html.Div(className="full-width", id="query-daily"), - type="circle", - ), - html.Div( - children=title_with_link( - text="Heatmap chart", - id_button="explore-heatmap-chart-label", - doc_link=DocLinks.TEMP_HUMIDITY_EXPLAINED, - ), - ), - dcc.Loading( - html.Div(className="full-width", id="query-heatmap"), - type="circle", - ), - html.Div( - children=title_with_tooltip( - text="Descriptive statistics", - tooltip_text="count, mean, std, min, max, and percentiles", - id_button="table-explore", - ), - ), - html.Div( - className="container-row justify-content-center", - children=[ - html.Div( - className=container_col_center_one_of_three, - children=[ - dbc.Button( - "Apply month and hour filter", - color="primary", - id="sec1-time-filter-input", - className="mb-2", - n_clicks=0, - ), - html.Div( - className=( - "container-row full-width justify-center mt-2" - ), - children=[ - html.H6("Month Range", style={"flex": "20%"}), - html.Div( - dcc.RangeSlider( - id="sec1-month-slider", - min=1, - max=12, - step=1, - value=[1, 12], - marks={1: "1", 12: "12"}, - tooltip={ - "always_visible": False, - "placement": "top", - }, - allowCross=False, - ), - style={"flex": "50%"}, - ), - dcc.Checklist( - options=[ - {"label": "Invert", "value": "invert"}, - ], - value=[], - id="invert-month-explore-descriptive", - labelStyle={"flex": "30%"}, - ), - ], - ), - html.Div( - className="container-row justify-center", - children=[ - html.H6("Hour Range", style={"flex": "20%"}), - html.Div( - dcc.RangeSlider( - id="sec1-hour-slider", - min=0, - max=24, - step=1, - value=[0, 24], - marks={0: "0", 24: "24"}, - tooltip={ - "always_visible": False, - "placement": "topLeft", - }, - allowCross=False, - ), - style={"flex": "50%"}, - ), - dcc.Checklist( - options=[ - {"label": "Invert", "value": "invert"}, - ], - value=[], - id="invert-hour-explore-descriptive", - labelStyle={"flex": "30%"}, - ), - ], - ), - ], - ), - ], - ), - html.Div( - id="table-data-explorer", - ), - ], - ) + return [ + section_one_inputs(), + title_with_link( + text="Yearly chart", + id_button=IdButtons.EXPLORE_YEARLY_CHART_LABEL, + doc_link=DocLinks.TEMP_HUMIDITY_EXPLAINED, + ), + dmc.Skeleton( + visible=False, h=450, children=dmc.Paper(id=ElementIds.YEARLY_EXPLORE) + ), + title_with_link( + text="Daily chart", + id_button=IdButtons.EXPLORE_DAILY_CHART_LABEL, + doc_link=DocLinks.TEMP_HUMIDITY_EXPLAINED, + ), + dmc.Skeleton( + visible=False, h=450, children=dmc.Paper(id=ElementIds.QUERY_DAILY) + ), + title_with_link( + text="Heatmap chart", + id_button=IdButtons.EXPLORE_HEATMAP_CHART_LABEL, + doc_link=DocLinks.TEMP_HUMIDITY_EXPLAINED, + ), + dmc.Skeleton( + visible=False, h=450, children=dmc.Paper(id=ElementIds.QUERY_HEATMAP) + ), + title_with_tooltip( + text="Descriptive statistics", + tooltip_text="count, mean, std, min, max, and percentiles", + id_button=IdButtons.TABLE_EXPLORE, + ), + # Results table + dmc.Paper(id=ElementIds.TABLE_DATA_EXPLORER, p="sm"), + ] def section_two_inputs(): """Return all the input forms from section two.""" - return html.Div( + return dmc.Stack( + p="md", children=[ - html.Div( - children=title_with_tooltip( - text="Customizable heatmap", - tooltip_text=None, - id_button="custom-heatmap-chart-label", - ), + title_with_tooltip( + text="Customizable heatmap", + tooltip_text=None, + id_button=IdButtons.CUSTOM_HEATMAP_CHART_LABEL, ), - html.Div( - className="container-row full-width three-inputs-container", + dmc.Grid( children=[ - html.Div( - className=container_col_center_one_of_three, - children=[ - html.Div( - className=container_row_center_full, - children=[ - html.H6( - children=["Variable:"], - style={"flex": "30%"}, - ), - dropdown( - id="sec2-var-dropdown", - options=explore_dropdown_names, - value="RH", - style={"flex": "70%"}, - ), - ], - ), - ], + dmc.GridCol( + dmc.Group( + [ + dmc.Title("Variable:", order=5), + dropdown( + id=ElementIds.SEC2_VAR_DROPDOWN, + options=explore_dropdown_names, + value=Variables.RH.col_name, + ), + ], + align="flex-start", + ), + span=4, ), - html.Div( - className=container_col_center_one_of_three, - children=[ - dbc.Button( - "Apply month and hour filter", - color="primary", - id="sec2-time-filter-input", - className="mb-2", - n_clicks=0, - ), - html.Div( - className=( - "container-row full-width justify-center mt-2" + dmc.GridCol( + dmc.Stack( + children=[ + dmc.Button( + "Apply filter", + id=ElementIds.SEC2_DATA_FILTER_INPUT, + color="blue", + w="50%", ), - children=[ - html.H6("Month Range", style={"flex": "20%"}), - html.Div( - dcc.RangeSlider( - id="sec2-month-slider", - min=1, - max=12, - step=1, - value=[1, 12], - marks={1: "1", 12: "12"}, - tooltip={ - "always_visible": False, - "placement": "top", - }, - allowCross=False, + dmc.Group( + [ + dmc.Title("Filter Variable:", order=5), + dropdown( + id=ElementIds.SEC2_DATA_FILTER_VAR, + options=explore_dropdown_names, + value=Variables.RH.col_name, ), - style={"flex": "50%"}, - ), - dcc.Checklist( - options=[ - {"label": "Invert", "value": "invert"}, - ], - value=[], - id="invert-month-explore-heatmap", - labelStyle={"flex": "30%"}, - ), - ], - ), - html.Div( - className="container-row justify-center", - children=[ - html.H6("Hour Range", style={"flex": "20%"}), - html.Div( - dcc.RangeSlider( - id="sec2-hour-slider", - min=0, - max=24, - step=1, - value=[0, 24], - marks={0: "0", 24: "24"}, - tooltip={ - "always_visible": False, - "placement": "topLeft", - }, - allowCross=False, + ], + ), + dmc.Group( + [ + dmc.Title("Min Value:", order=5), + dmc.NumberInput( + id=ElementIds.SEC2_MIN_VAL, + placeholder="Enter a number for the min val", + value=0, ), - style={"flex": "50%"}, - ), - dcc.Checklist( - options=[ - {"label": "Invert", "value": "invert"}, - ], - value=[], - id="invert-hour-explore-heatmap", - labelStyle={"flex": "30%"}, - ), - ], - ), - ], - ), - html.Div( - className=container_col_center_one_of_three, - children=[ - dbc.Button( - "Apply filter", - color="primary", - id="sec2-data-filter-input", - className="mb-2", - n_clicks=0, - ), - html.Div( - className=container_row_center_full, - children=[ - html.H6( - children=["Filter Variable:"], - style={"flex": "30%"}, - ), - dropdown( - id="sec2-data-filter-var", - options=explore_dropdown_names, - value="RH", - style={"flex": "70%"}, - ), - ], - ), - html.Div( - className=container_row_center_full, - children=[ - html.H6( - children=["Min Value:"], style={"flex": "30%"} - ), - dbc.Input( - id="sec2-min-val", - placeholder="Enter a number for the min val", - type="number", - value=0, - step=1, - style={"flex": "70%"}, - ), - ], - ), - html.Div( - className=container_row_center_full, - children=[ - html.H6( - children=["Max Value:"], style={"flex": "30%"} - ), - dbc.Input( - id="sec2-max-val", - placeholder="Enter a number for the max val", - type="number", - value=100, - step=1, - style={"flex": "70%"}, - ), - ], - ), - ], + ], + ), + dmc.Group( + [ + dmc.Title("Max Value:", order=5), + dmc.NumberInput( + id=ElementIds.SEC2_MAX_VAL, + placeholder="Enter a number for the max val", + value=100, + ), + ], + ), + ], + ), + span=8, ), ], ), @@ -380,207 +199,123 @@ def section_two_inputs(): def section_two(): """Return the two graphs in section two.""" - return html.Div( - id="tab6-sec2-container", - className="container-col justify-center full-width", + return dmc.Stack( + id=ElementIds.EXPLORER_SEC2_CONTAINER, children=[ section_two_inputs(), dcc.Loading( type="circle", - children=html.Div(className="full-width", id="custom-heatmap"), + children=dmc.Paper( + id=ElementIds.CUSTOM_HEATMAP, + p="sm", + ), ), - dbc.Checklist( - options=[ - {"label": "Normalize", "value": "normal"}, + dmc.Group( + children=[ + dmc.CheckboxGroup( + id=ElementIds.NORMALIZE, + value=[], + children=[ + dmc.Checkbox(label="Normalize", value="normal"), + ], + ), ], - value=[], - id="normalize", ), dcc.Loading( type="circle", - children=[ - dcc.Graph( - className="full-width", id="custom-summary", config=fig_config + children=dmc.Paper( + children=dcc.Graph( + id=ElementIds.CUSTOM_SUMMARY, + config=fig_config, ), - ], + ), ), ], ) def section_three_inputs(): - """""" - return html.Div( - className="container-row full-width three-inputs-container", + return dmc.Grid( children=[ - html.Div( - className=container_col_center_one_of_three, - children=[ - html.Div( - className=container_row_center_full, - children=[ - html.H6(style={"flex": "30%"}, children=["X Variable:"]), - dropdown( - id="tab6-sec3-var-x-dropdown", - options=explore_dropdown_names, - value="DBT", - style={"flex": "70%"}, - ), - ], - ), - html.Div( - className=container_row_center_full, - children=[ - html.H6(style={"flex": "30%"}, children=["Y Variable:"]), - dropdown( - id="tab6-sec3-var-y-dropdown", - options=explore_dropdown_names, - value="RH", - style={"flex": "70%"}, - ), - ], - ), - html.Div( - className=container_row_center_full, - children=[ - html.H6(style={"flex": "30%"}, children=["Color By:"]), - dropdown( - id="tab6-sec3-colorby-dropdown", - options=explore_dropdown_names, - value="glob_hor_rad", - style={"flex": "70%"}, - ), - ], - ), - ], - ), - html.Div( - className=container_col_center_one_of_three, - children=[ - dbc.Button( - "Apply month and hour filter", - color="primary", - id="tab6-sec3-time-filter-input", - className="mb-2", - n_clicks=0, - ), - html.Div( - className="container-row full-width justify-center", - children=[ - html.H6("Month Range", style={"flex": "20%"}), - html.Div( - dcc.RangeSlider( - id="tab6-sec3-query-month-slider", - min=1, - max=12, - step=1, - value=[1, 12], - marks={1: "1", 12: "12"}, - tooltip={ - "always_visible": False, - "placement": "top", - }, - allowCross=False, + dmc.GridCol( + dmc.Stack( + [ + dmc.Group( + [ + dmc.Title("X Variable:", order=5), + dropdown( + id=ElementIds.EXPLORER_SEC3_VAR_X_DROPDOWN, + options=explore_dropdown_names, + value="DBT", ), - style={"flex": "50%"}, - ), - dcc.Checklist( - options=[ - {"label": "Invert", "value": "invert"}, - ], - value=[], - id="invert-month-explore-more-charts", - labelStyle={"flex": "30%"}, - ), - ], - ), - html.Div( - className="container-row full-width justify-center", - children=[ - html.H6("Hour Range", style={"flex": "20%"}), - html.Div( - dcc.RangeSlider( - id="tab6-sec3-query-hour-slider", - min=0, - max=24, - step=1, - value=[0, 24], - marks={0: "0", 24: "24"}, - tooltip={ - "always_visible": False, - "placement": "top", - }, - allowCross=False, + ], + ), + dmc.Group( + [ + dmc.Title("Y Variable:", order=5), + dropdown( + id=ElementIds.EXPLORER_SEC3_VAR_Y_DROPDOWN, + options=explore_dropdown_names, + value=Variables.RH.col_name, ), - style={"flex": "50%"}, - ), - dcc.Checklist( - options=[ - {"label": "Invert", "value": "invert"}, - ], - value=[], - id="invert-hour-explore-more-charts", - labelStyle={"flex": "30%"}, - ), - ], - ), - ], + ], + ), + dmc.Group( + [ + dmc.Title("Color By:", order=5), + dropdown( + id=ElementIds.EXPLORER_SEC3_COLORBY_DROPDOWN, + options=explore_dropdown_names, + value="glob_hor_rad", + ), + ], + ), + ], + ), + span=4, ), - html.Div( - className=container_col_center_one_of_three, - children=[ - dbc.Button( - "Apply filter", - color="primary", - id="tab6-sec3-data-filter-input", - className="mb-2", - n_clicks=0, - ), - html.Div( - className=container_row_center_full, - children=[ - html.H6( - children=["Filter Variable:"], style={"flex": "30%"} - ), - dropdown( - id="tab6-sec3-filter-var-dropdown", - options=explore_dropdown_names, - value="RH", - style={"flex": "70%"}, - ), - ], - ), - html.Div( - className=container_row_center_full, - children=[ - html.H6(children=["Min Value:"], style={"flex": "30%"}), - dbc.Input( - className="num-input", - id="tab6-sec3-min-val", - placeholder="Enter a number for the min val", - type="number", - step=1, - value=0, - style={"flex": "70%"}, - ), - ], - ), - html.Div( - className=container_row_center_full, - children=[ - html.H6(children=["Max Value:"], style={"flex": "30%"}), - dbc.Input( - className="num-input", - id="tab6-sec3-max-val", - placeholder="Enter a number for the max val", - type="number", - value=100, - step=1, - style={"flex": "70%"}, - ), - ], - ), - ], + dmc.GridCol( + dmc.Stack( + [ + dmc.Button( + "Apply filter", + id=ElementIds.EXPLORER_SEC3_DATA_FILTER_INPUT, + color="blue", + w="45%", + ), + dmc.Group( + [ + dmc.Title("Filter Variable:", order=5), + dropdown( + id=ElementIds.EXPLORER_SEC3_FILTER_VAR_DROPDOWN, + options=explore_dropdown_names, + value=Variables.RH.col_name, + ), + ], + ), + dmc.Group( + [ + dmc.Title("Min Value:", order=5), + dmc.NumberInput( + id=ElementIds.EXPLORER_SEC3_MIN_VAL, + placeholder="Enter a number for the min val", + value=0, + ), + ], + ), + dmc.Group( + [ + dmc.Title("Max Value:", order=5), + dmc.NumberInput( + id=ElementIds.EXPLORER_SEC3_MAX_VAL, + placeholder="Enter a number for the max val", + value=100, + ), + ], + ), + ], + ), + span=8, ), ], ) @@ -588,56 +323,63 @@ def section_three_inputs(): def section_three(): """Return the two graphs in section three.""" - return html.Div( - className="container-col full-width", + return dmc.Stack( children=[ - html.Div( - children=title_with_tooltip( - text="More charts", - tooltip_text=None, - id_button="more-charts-label", - ), + title_with_tooltip( + text="More charts", + tooltip_text=None, + id_button=IdButtons.MORE_CHARTS_LABEL, ), section_three_inputs(), dcc.Loading( - html.Div(id="three-var"), type="circle", + children=dmc.Paper( + id=ElementIds.THREE_VAR, + ), ), dcc.Loading( - html.Div(id="two-var"), type="circle", + children=dmc.Paper( + id=ElementIds.TWO_VAR, + ), ), ], ) -def layout(): - """Return the contents of tab six.""" - return html.Div( - className="justify-center", - children=[section_one(), section_two(), section_three()], - ) - - @callback( - Output("yearly-explore", "children"), + Output(ElementIds.YEARLY_EXPLORE, "children"), # Section One [ - Input("df-store", "modified_timestamp"), - Input("sec1-var-dropdown", "value"), - Input("global-local-radio-input", "value"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.SEC1_VAR_DROPDOWN, "value"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) -def update_tab_yearly(_, var, global_local, df, meta, si_ip): +def update_tab_yearly(_, var, global_local, global_filter_data, df, meta, si_ip): """Update the contents of tab size. Passing in the info from the dropdown and the general info.""" + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + target_columns = [ + var, + Variables.ADAPTIVE_CMF_80_LOW.col_name, + Variables.ADAPTIVE_CMF_80_UP.col_name, + Variables.ADAPTIVE_CMF_90_LOW.col_name, + Variables.ADAPTIVE_CMF_90_UP.col_name, + Variables.ADAPTIVE_CMF_RMT.col_name, + ] + df = apply_global_month_hour_filter(df, global_filter_data, target_columns) + if df[var].mean() == 99990.0: - return dbc.Alert( + return dmc.Alert( """The selected variable is not available, the Clima tool could not generate the yearly plot""", color="warning", @@ -647,56 +389,75 @@ def update_tab_yearly(_, var, global_local, df, meta, si_ip): custom_inputs = generate_custom_inputs(var) units = generate_units(si_ip) return dcc.Graph( - config=generate_chart_name("yearly_explore", meta, custom_inputs, units), + config=generate_chart_name( + TabNames.YEARLY_EXPLORE, meta, custom_inputs, units + ), figure=yearly_profile(df, var, global_local, si_ip), ) @callback( - Output("query-daily", "children"), + Output(ElementIds.QUERY_DAILY, "children"), [ - Input("df-store", "modified_timestamp"), - Input("sec1-var-dropdown", "value"), - Input("global-local-radio-input", "value"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.SEC1_VAR_DROPDOWN, "value"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) -def update_tab_daily(_, var, global_local, df, meta, si_ip): +def update_tab_daily(_, var, global_local, global_filter_data, df, meta, si_ip): """Update the contents of tab size. Passing in the info from the dropdown and the general info.""" + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + df = apply_global_month_hour_filter(df, global_filter_data) + custom_inputs = generate_custom_inputs(var) units = generate_units(si_ip) return ( dcc.Graph( - config=generate_chart_name("daily_explore", meta, custom_inputs, units), + config=generate_chart_name( + TabNames.DAILY_EXPLORE, meta, custom_inputs, units + ), figure=daily_profile(df, var, global_local, si_ip), ), ) @callback( - Output("query-heatmap", "children"), + Output(ElementIds.QUERY_HEATMAP, "children"), [ - Input("df-store", "modified_timestamp"), - Input("sec1-var-dropdown", "value"), - Input("global-local-radio-input", "value"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.SEC1_VAR_DROPDOWN, "value"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) -def update_tab_heatmap(_, var, global_local, df, meta, si_ip): +def update_tab_heatmap(_, var, global_local, global_filter_data, df, meta, si_ip): + """Update the contents of tab size. Passing in the info from the dropdown and the general info.""" """Update the contents of tab size. Passing in the info from the dropdown and the general info.""" + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + df = apply_global_month_hour_filter(df, global_filter_data) + custom_inputs = generate_custom_inputs(var) units = generate_units(si_ip) return ( dcc.Graph( - config=generate_chart_name("heatmap_explore", meta, custom_inputs, units), + config=generate_chart_name( + TabNames.HEATMAP_EXPLORE, meta, custom_inputs, units + ), figure=heatmap(df, var, global_local, si_ip), ), ) @@ -704,62 +465,68 @@ def update_tab_heatmap(_, var, global_local, df, meta, si_ip): @callback( [ - Output("custom-heatmap", "children"), - Output("custom-summary", "style"), - Output("custom-summary", "figure"), - Output("normalize", "style"), + Output(ElementIds.CUSTOM_HEATMAP, "children"), + Output(ElementIds.CUSTOM_SUMMARY, "style"), + Output(ElementIds.CUSTOM_SUMMARY, "figure"), + Output(ElementIds.NORMALIZE, "style"), ], [ - Input("df-store", "modified_timestamp"), - Input("sec2-var-dropdown", "value"), - Input("sec2-time-filter-input", "n_clicks"), - Input("sec2-data-filter-input", "n_clicks"), - Input("normalize", "value"), - Input("global-local-radio-input", "value"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.SEC2_VAR_DROPDOWN, "value"), + Input(ElementIds.SEC2_DATA_FILTER_INPUT, "n_clicks"), + Input(ElementIds.NORMALIZE, "value"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], # General [ - State("df-store", "data"), - State("sec2-month-slider", "value"), - State("sec2-hour-slider", "value"), - State("sec2-data-filter-var", "value"), - State("sec2-min-val", "value"), - State("sec2-max-val", "value"), - State("meta-store", "data"), - State("invert-month-explore-heatmap", "value"), - State("invert-hour-explore-heatmap", "value"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SEC2_DATA_FILTER_VAR, "value"), + State(ElementIds.SEC2_MIN_VAL, "value"), + State(ElementIds.SEC2_MAX_VAL, "value"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) def update_heatmap( _, var, - time_filter, data_filter, normalize, global_local, + global_filter_data, df, - month, - hour, filter_var, min_val, max_val, meta, - invert_month, - invert_hour, si_ip, ): - df = filter_df_by_month_and_hour( - df, time_filter, month, hour, invert_month, invert_hour, var - ) - data_filter_info = [data_filter, filter_var, min_val, max_val] + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import ( + apply_global_month_hour_filter, + get_global_filter_state, + ) - start_month, end_month, start_hour, end_hour = determine_month_and_hour_filter( - month, hour, invert_month, invert_hour - ) + df = apply_global_month_hour_filter(df, global_filter_data, var) + + filter_state = get_global_filter_state(global_filter_data) + month_range = filter_state["month_range"] + hour_range = filter_state["hour_range"] + invert_month_global = filter_state["invert_month"] + invert_hour_global = filter_state["invert_hour"] + + start_month, end_month, start_hour, end_hour = determine_month_and_hour_filter( + month_range, hour_range, invert_month_global, invert_hour_global + ) + else: + # Use default values when global filter is not active + start_month, end_month, start_hour, end_hour = 1, 12, 0, 24 + + data_filter_info = [data_filter, filter_var, min_val, max_val] month = [start_month, end_month] hour = [start_hour, end_hour] - time_filter_info = [time_filter, month, hour] + time_filter_info = [True, month, hour] heat_map = custom_heatmap( df, global_local, var, time_filter_info, data_filter_info, si_ip @@ -769,7 +536,7 @@ def update_heatmap( if not heat_map: return ( - dbc.Alert( + dmc.Alert( "No data is available in this location under these conditions. Please " "either change the month and hour filters, or select a wider range for " "the filter variable", @@ -795,7 +562,9 @@ def update_heatmap( units = generate_units(si_ip) return ( dcc.Graph( - config=generate_chart_name("heatmap", meta, custom_inputs, units), + config=generate_chart_name( + TabNames.HEATMAP, meta, custom_inputs, units + ), figure=heat_map, ), {}, @@ -810,7 +579,7 @@ def update_heatmap( return ( dcc.Graph( - config=generate_chart_name("heatmap", meta, custom_inputs, units), + config=generate_chart_name(TabNames.HEATMAP, meta, custom_inputs, units), figure=heat_map, ), no_display, @@ -820,27 +589,23 @@ def update_heatmap( @callback( - [Output("three-var", "children"), Output("two-var", "children")], + [Output(ElementIds.THREE_VAR, "children"), Output(ElementIds.TWO_VAR, "children")], [ - Input("df-store", "modified_timestamp"), - Input("tab6-sec3-var-x-dropdown", "value"), - Input("tab6-sec3-var-y-dropdown", "value"), - Input("tab6-sec3-colorby-dropdown", "value"), - Input("tab6-sec3-time-filter-input", "n_clicks"), - Input("tab6-sec3-data-filter-input", "n_clicks"), - Input("global-local-radio-input", "value"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.EXPLORER_SEC3_VAR_X_DROPDOWN, "value"), + Input(ElementIds.EXPLORER_SEC3_VAR_Y_DROPDOWN, "value"), + Input(ElementIds.EXPLORER_SEC3_COLORBY_DROPDOWN, "value"), + Input(ElementIds.EXPLORER_SEC3_DATA_FILTER_INPUT, "n_clicks"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("tab6-sec3-query-month-slider", "value"), - State("tab6-sec3-query-hour-slider", "value"), - State("tab6-sec3-filter-var-dropdown", "value"), - State("tab6-sec3-min-val", "value"), - State("tab6-sec3-max-val", "value"), - State("meta-store", "data"), - State("invert-month-explore-more-charts", "value"), - State("invert-hour-explore-more-charts", "value"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.EXPLORER_SEC3_FILTER_VAR_DROPDOWN, "value"), + State(ElementIds.EXPLORER_SEC3_MIN_VAL, "value"), + State(ElementIds.EXPLORER_SEC3_MAX_VAL, "value"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) def update_more_charts( @@ -848,18 +613,14 @@ def update_more_charts( var_x, var_y, color_by, - time_filter, data_filter, global_local, + global_filter_data, df, - month, - hour, data_filter_var, min_val, max_val, meta, - invert_month, - invert_hour, si_ip, ): """Update the contents of tab size. Passing in the info from the dropdown and the general info.""" @@ -867,9 +628,13 @@ def update_more_charts( # if (min_val3 is None or max_val3 is None) and data_filter3: # raise PreventUpdate - df = filter_df_by_month_and_hour( - df, time_filter, month, hour, invert_month, invert_hour, df.columns - ) + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + df = apply_global_month_hour_filter(df, global_filter_data) + else: + # Use local filtering when global filter is not active + df = filter_df_by_month_and_hour(df, True, [1, 12], [0, 24], [], [], df.columns) data_filter_info = [data_filter, data_filter_var, min_val, max_val] if data_filter and (min_val is None or max_val is None): @@ -888,14 +653,16 @@ def update_more_charts( if not three: custom_inputs = f"{var_x}-{var_y}" units = generate_units(si_ip) - return dbc.Alert( + return dmc.Alert( "No data is available in this location under these conditions. Please " "either change the month and hour filters, or select a wider range for " "the filter variable", color="danger", style={"text-align": "center", "marginTop": "2rem"}, ), dcc.Graph( - config=generate_chart_name("scatter", meta, custom_inputs, units), + config=generate_chart_name( + TabNames.SCATTER, meta, custom_inputs, units + ), figure=two, ) else: @@ -903,51 +670,57 @@ def update_more_charts( custom_inputs_two = f"{var_x}-{var_y}" units = generate_units(si_ip) return dcc.Graph( - config=generate_chart_name("scatter", meta, custom_inputs_three, units), + config=generate_chart_name( + TabNames.SCATTER, meta, custom_inputs_three, units + ), figure=three, ), dcc.Graph( - config=generate_chart_name("scatter", meta, custom_inputs_two, units), + config=generate_chart_name( + TabNames.SCATTER, meta, custom_inputs_two, units + ), figure=two, ) @callback( - Output("table-data-explorer", "children"), + Output(ElementIds.TABLE_DATA_EXPLORER, "children"), [ - Input("df-store", "modified_timestamp"), - Input("sec1-var-dropdown", "value"), - Input("sec1-time-filter-input", "n_clicks"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.SEC1_VAR_DROPDOWN, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("si-ip-unit-store", "data"), - State("sec1-month-slider", "value"), - State("sec1-hour-slider", "value"), - State("invert-month-explore-descriptive", "value"), - State("invert-hour-explore-descriptive", "value"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) def update_table( _, dd_value, - __, + global_filter_data, df, si_ip, - month_range, - hour_range, - invert_month, - invert_hour, ): - start_month, end_month, start_hour, end_hour = determine_month_and_hour_filter( - month_range, hour_range, invert_month, invert_hour - ) + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + filtered_df = apply_global_month_hour_filter(df, global_filter_data) + # Filter out the filtered rows to avoid empty columns + if "_is_filtered" in filtered_df.columns: + filtered_df = filtered_df[~filtered_df["_is_filtered"]] + else: + # Use default values when global filter is not active + filtered_df = df - filtered_df = df[ - (df["month"] >= start_month) - & (df["month"] <= end_month) - & (df["hour"] >= start_hour) - & (df["hour"] <= end_hour) - ] return summary_table_tmp_rh_tab( - filtered_df[["month", "hour", dd_value, "month_names"]], dd_value, si_ip + filtered_df[ + [ + Variables.MONTH.col_name, + Variables.HOUR.col_name, + dd_value, + Variables.MONTH_NAMES.col_name, + ] + ], + dd_value, + si_ip, ) diff --git a/pages/lib/charts_data_explorer.py b/pages/lib/charts_data_explorer.py index 2a085bd3..0d7f8401 100644 --- a/pages/lib/charts_data_explorer.py +++ b/pages/lib/charts_data_explorer.py @@ -1,10 +1,9 @@ -from math import ceil, floor - import numpy as np -import math import plotly.express as px import plotly.graph_objects as go -from pages.lib.global_scheme import template, mapping_dictionary, month_lst +from pages.lib.utils import get_max_min_value +from pages.lib.global_scheme import template, month_lst +from pages.lib.global_variables import Variables, VariableInfo def custom_heatmap(df, global_local, var, time_filter_info, data_filter_info, si_ip): @@ -30,20 +29,23 @@ def custom_heatmap(df, global_local, var, time_filter_info, data_filter_info, si if df.dropna(subset=[var]).shape[0] == 0: return None - var_unit = mapping_dictionary[var][si_ip]["unit"] - var_range = mapping_dictionary[var][si_ip]["range"] - var_name = mapping_dictionary[var]["name"] - var_color = mapping_dictionary[var]["color"] - filter_name = mapping_dictionary[filter_var]["name"] - filter_unit = mapping_dictionary[filter_var][si_ip]["unit"] + variable = VariableInfo.from_col_name(var) + filter_variable = VariableInfo.from_col_name(filter_var) + + var_name = variable.get_name() + var_unit = variable.get_unit(si_ip) + var_range = variable.get_range(si_ip) + var_color = variable.get_color() + + filter_name = filter_variable.get_name() + filter_unit = filter_variable.get_unit(si_ip) if global_local == "global": # Set Global values for Max and minimum range_z = var_range else: # Set maximum and minimum according to data - data_max = 5 * ceil(df[var].max() / 5) - data_min = 5 * floor(df[var].min() / 5) + data_max, data_min = get_max_min_value(df[var]) range_z = [data_min, data_max] title = var_name + " (" + var_unit + ")" @@ -58,31 +60,101 @@ def custom_heatmap(df, global_local, var, time_filter_info, data_filter_info, si f" when the {filter_name} is between {min_val} and {max_val} {filter_unit}" ) - fig = go.Figure( - data=go.Heatmap( - y=df["hour"], - x=df["DOY"], - z=df[var], - colorscale=var_color, - zmin=range_z[0], - zmax=range_z[1], - connectgaps=False, - hoverongaps=False, - customdata=np.stack((df["month_names"], df["day"]), axis=-1), - hovertemplate=( - "" - + var - + ": %{z:.2f} " - + var_unit - + "
" - + "Month: %{customdata[0]}
" - + "Day: %{customdata[1]}
" - + "Hour: %{y}:00
" - ), - name="", - colorbar=dict(title=var_unit), + fig = go.Figure() + + has_filter_marker = "_is_filtered" in df.columns + + if has_filter_marker and df["_is_filtered"].any(): + filtered_mask = df["_is_filtered"] + if filtered_mask.any(): + original_col = f"_{var}_original" + col_to_use = original_col if original_col in df.columns else var + filtered_values = df[col_to_use].copy() + filtered_values[~filtered_mask] = None + + fig.add_trace( + go.Heatmap( + y=df[Variables.HOUR.col_name], + x=df[Variables.DOY.col_name], + z=filtered_values, + colorscale=[[0, "lightgray"], [1, "gray"]], + zmin=range_z[0], + zmax=range_z[1], + showscale=False, + connectgaps=False, + hoverongaps=False, + customdata=np.stack( + (df[Variables.MONTH.col_name], df[Variables.DAY.col_name]), + axis=-1, + ), + hovertemplate=( + "Filtered Data
" + + "Month: %{customdata[0]}
Day: %{customdata[1]}
Hour:" + " %{y}:00
" + ), + name="filtered", + ) + ) + + base_values = df[var].copy() + base_values[filtered_mask] = None + + fig.add_trace( + go.Heatmap( + y=df[Variables.HOUR.col_name], + x=df[Variables.DOY.col_name], + z=base_values, + colorscale=var_color, + zmin=range_z[0], + zmax=range_z[1], + connectgaps=False, + hoverongaps=False, + customdata=np.stack( + (df[Variables.MONTH.col_name], df[Variables.DAY.col_name]), axis=-1 + ), + hovertemplate=( + "" + + var + + ": %{z:.2f} " + + var_unit + + "
" + + "Month: %{customdata[0]}
" + + "Day: %{customdata[1]}
" + + "Hour: %{y}:00
" + ), + name="", + colorbar=dict(title=var_unit), + ) + ) + else: + fig.add_trace( + go.Heatmap( + y=df[Variables.HOUR.col_name], + x=df[Variables.DOY.col_name], + z=df[var], + colorscale=var_color, + zmin=range_z[0], + zmax=range_z[1], + connectgaps=False, + hoverongaps=False, + customdata=np.stack( + (df[Variables.MONTH_NAMES.col_name], df[Variables.DAY.col_name]), + axis=-1, + ), + hovertemplate=( + "" + + var + + ": %{z:.2f} " + + var_unit + + "
" + + "Month: %{customdata[0]}
" + + "Day: %{customdata[1]}
" + + "Hour: %{y}:00
" + ), + name="", + colorbar=dict(title=var_unit), + ) ) - ) fig.update_layout( template=template, title=title, @@ -111,17 +183,20 @@ def three_var_graph( min_val = data_filter_info3[2] max_val = data_filter_info3[3] - var_unit_x = mapping_dictionary[var_x][si_ip]["unit"] - var_unit_y = mapping_dictionary[var_y][si_ip]["unit"] + variable_x = VariableInfo.from_col_name(var_x) + variable_y = VariableInfo.from_col_name(var_y) + variable_color = VariableInfo.from_col_name(color_by) + + var_unit_x = variable_x.get_unit(si_ip) + var_unit_y = variable_y.get_unit(si_ip) + var_range = variable_color.get_range(si_ip) + var_color = variable_color.get_color() var = color_by - var_range = mapping_dictionary[var][si_ip]["range"] - var_color = mapping_dictionary[var]["color"] if global_local != "global": # Set maximum and minimum according to data - data_max = 5 * math.ceil(df[var].max() / 5) - data_min = 5 * math.floor(df[var].min() / 5) + data_max, data_min = get_max_min_value(df[var]) var_range = [data_min, data_max] color_scale = var_color @@ -132,15 +207,15 @@ def three_var_graph( else: df.loc[(df[filter_var] >= max_val) & (df[filter_var] <= min_val)] = None - if df.dropna(subset=["month"]).shape[0] == 0: + if df.dropna(subset=[Variables.MONTH.col_name]).shape[0] == 0: return None title = ( - mapping_dictionary[var_x]["name"] + variable_x.get_name() + " vs " - + mapping_dictionary[var_y]["name"] + + variable_y.get_name() + " colored by " - + mapping_dictionary[color_by]["name"] + + variable_color.get_name() ) fig = px.scatter( @@ -165,15 +240,18 @@ def three_var_graph( def two_var_graph(df, var_x, var_y, si_ip): + variable_x = VariableInfo.from_col_name(var_x) + variable_y = VariableInfo.from_col_name(var_y) + title = ( "Simultaneous frequency of " - + mapping_dictionary[var_x]["name"] + + variable_x.get_name() + " and " - + mapping_dictionary[var_y]["name"] + + variable_y.get_name() ) - var_unit_x = mapping_dictionary[var_x][si_ip]["unit"] - var_unit_y = mapping_dictionary[var_y][si_ip]["unit"] + var_unit_x = variable_x.get_unit(si_ip) + var_unit_y = variable_y.get_unit(si_ip) fig = px.density_heatmap( df, diff --git a/pages/lib/charts_summary.py b/pages/lib/charts_summary.py index ede7b264..be3eb9be 100644 --- a/pages/lib/charts_summary.py +++ b/pages/lib/charts_summary.py @@ -1,14 +1,15 @@ import pandas as pd import plotly.express as px +from pages.lib.global_variables import Variables def world_map(meta): """Return the world map showing the current location.""" - latitude = float(meta["lat"]) - longitude = float(meta["lon"]) - city = meta["city"] - country = meta["country"] - time_zone = float(meta["time_zone"]) + latitude = float(meta[Variables.LAT.col_name]) + longitude = float(meta[Variables.LON.col_name]) + city = meta[Variables.CITY.col_name] + country = meta[Variables.COUNTRY.col_name] + time_zone = float(meta[Variables.TIME_ZONE.col_name]) lat_long_df = pd.DataFrame( data={ "Lat": [latitude], diff --git a/pages/lib/charts_sun.py b/pages/lib/charts_sun.py index 73d43a24..d48eb498 100644 --- a/pages/lib/charts_sun.py +++ b/pages/lib/charts_sun.py @@ -1,29 +1,78 @@ from datetime import timedelta -from math import ceil, cos, floor, radians +from math import cos, radians import numpy as np import pandas as pd import plotly.graph_objects as go from config import UnitSystem +from pages.lib.utils import get_max_min_value from pages.lib.global_scheme import ( template, - mapping_dictionary, degrees_unit, tight_margins, month_lst, ) from plotly.subplots import make_subplots from pvlib import solarposition +from pages.lib.global_variables import Variables, VariableInfo +from pages.lib.utils import separate_filtered_data def monthly_solar(epw_df, si_ip): + # Separate filtered and unfiltered data + # Note: monthly_solar uses two original columns (GLOB_HOR_RAD and DIF_HOR_RAD) + # so we can't use the utility function directly, but we can still use it for separation + filter_info = separate_filtered_data(epw_df) + df_unfiltered = filter_info["df_unfiltered"] + df_filtered = filter_info["df_filtered"] + has_filter_marker = filter_info["has_filter_marker"] + + # Get original values if available (for two specific columns) + original_glob_col = f"_{Variables.GLOB_HOR_RAD.col_name}_original" + original_dif_col = f"_{Variables.DIF_HOR_RAD.col_name}_original" + use_original_for_filtered = ( + has_filter_marker + and original_glob_col in epw_df.columns + and original_dif_col in epw_df.columns + ) + + # Calculate monthly averages for unfiltered data g_h_rad_month_ave = ( - epw_df.groupby(["month", "hour"])["glob_hor_rad"].median().reset_index() + df_unfiltered.groupby([Variables.MONTH.col_name, Variables.HOUR.col_name])[ + Variables.GLOB_HOR_RAD.col_name + ] + .median() + .reset_index() ) dif_h_rad_month_ave = ( - epw_df.groupby(["month", "hour"])["dif_hor_rad"].median().reset_index() + df_unfiltered.groupby([Variables.MONTH.col_name, Variables.HOUR.col_name])[ + Variables.DIF_HOR_RAD.col_name + ] + .median() + .reset_index() ) + + # Calculate monthly averages for filtered data (using original values) + g_h_rad_month_ave_filtered = None + dif_h_rad_month_ave_filtered = None + if df_filtered is not None and len(df_filtered) > 0 and use_original_for_filtered: + g_h_rad_month_ave_filtered = ( + df_filtered.groupby([Variables.MONTH.col_name, Variables.HOUR.col_name])[ + original_glob_col + ] + .median() + .reset_index() + ) + dif_h_rad_month_ave_filtered = ( + df_filtered.groupby([Variables.MONTH.col_name, Variables.HOUR.col_name])[ + original_dif_col + ] + .median() + .reset_index() + ) + + # Always show 12 months in horizontal layout fig = make_subplots( rows=1, cols=12, @@ -31,69 +80,173 @@ def monthly_solar(epw_df, si_ip): shared_yaxes=True, ) - for i in range(12): - # We only need legend entries for the first pair, since the others repeat. - is_first = i == 0 - - fig.add_trace( - go.Scatter( - x=g_h_rad_month_ave.loc[g_h_rad_month_ave["month"] == i + 1, "hour"], - y=g_h_rad_month_ave.loc[ - g_h_rad_month_ave["month"] == i + 1, "glob_hor_rad" - ], - fill="tozeroy", - mode="lines", - line_color="orange", - line_width=2, - name="Global", - showlegend=is_first, - customdata=epw_df.loc[epw_df["month"] == i + 1, "month_names"], - hovertemplate=( - "" - + "Global Horizontal Solar Radiation" - + ": %{y:.2f} " - + mapping_dictionary["glob_hor_rad"][si_ip]["unit"] - + "
" - + "Month: %{customdata}
" - + "Hour: %{x}:00
" - + "" # Hides the "secondary box" + # Track which legend entries have been shown (only show once) + legend_shown = { + "Global": False, + "Diffuse": False, + } + + for month_num in range(1, 13): + col_idx = month_num + + # Add filtered data traces (gray) if any + if ( + df_filtered is not None + and len(df_filtered) > 0 + and g_h_rad_month_ave_filtered is not None + ): + month_glob_filtered = g_h_rad_month_ave_filtered.loc[ + g_h_rad_month_ave_filtered[Variables.MONTH.col_name] == month_num + ] + if len(month_glob_filtered) > 0: + # Get the column name from the groupby result (it should be the original column name) + filtered_glob_col = ( + original_glob_col + if original_glob_col in month_glob_filtered.columns + else Variables.GLOB_HOR_RAD.col_name + ) + fig.add_trace( + go.Scatter( + x=month_glob_filtered[Variables.HOUR.col_name], + y=month_glob_filtered[filtered_glob_col], + fill="tozeroy", + mode="lines", + line_color="gray", + line_width=1, + name="Global (Filtered)", + showlegend=False, + customdata=[month_lst[month_num - 1]] * len(month_glob_filtered) + if len(month_glob_filtered) > 0 + else [], + hovertemplate=( + "Filtered Data
" + + "Global Horizontal Solar Radiation" + + ": %{y:.2f} " + + VariableInfo.from_col_name( + Variables.GLOB_HOR_RAD.col_name + ).get_unit(si_ip) + + "
" + + "Month: %{customdata}
" + + "Hour: %{x}:00
" + + "" + ), + ), + row=1, + col=col_idx, + ) + + month_dif_filtered = dif_h_rad_month_ave_filtered.loc[ + dif_h_rad_month_ave_filtered[Variables.MONTH.col_name] == month_num + ] + if len(month_dif_filtered) > 0: + # Get the column name from the groupby result (it should be the original column name) + filtered_dif_col = ( + original_dif_col + if original_dif_col in month_dif_filtered.columns + else Variables.DIF_HOR_RAD.col_name + ) + fig.add_trace( + go.Scatter( + x=month_dif_filtered[Variables.HOUR.col_name], + y=month_dif_filtered[filtered_dif_col], + fill="tozeroy", + mode="lines", + line_color="lightgray", + line_width=1, + name="Diffuse (Filtered)", + showlegend=False, + customdata=[month_lst[month_num - 1]] * len(month_dif_filtered) + if len(month_dif_filtered) > 0 + else [], + hovertemplate=( + "Filtered Data
" + + "Diffuse Horizontal Solar Radiation" + + ": %{y:.2f} " + + VariableInfo.from_col_name( + Variables.DIF_HOR_RAD.col_name + ).get_unit(si_ip) + + "
" + + "Month: %{customdata}
" + + "Hour: %{x}:00
" + + "" + ), + ), + row=1, + col=col_idx, + ) + + # Add unfiltered data traces (normal colors) + month_glob_unfiltered = g_h_rad_month_ave.loc[ + g_h_rad_month_ave[Variables.MONTH.col_name] == month_num + ] + if len(month_glob_unfiltered) > 0: + fig.add_trace( + go.Scatter( + x=month_glob_unfiltered[Variables.HOUR.col_name], + y=month_glob_unfiltered[Variables.GLOB_HOR_RAD.col_name], + fill="tozeroy", + mode="lines", + line_color="orange", + line_width=2, + name="Global", + legendgroup="Global", + showlegend=not legend_shown["Global"], + customdata=[month_lst[month_num - 1]] * len(month_glob_unfiltered), + hovertemplate=( + "" + + "Global Horizontal Solar Radiation" + + ": %{y:.2f} " + + VariableInfo.from_col_name( + Variables.GLOB_HOR_RAD.col_name + ).get_unit(si_ip) + + "
" + + "Month: %{customdata}
" + + "Hour: %{x}:00
" + + "" # Hides the "secondary box" + ), ), - ), - row=1, - col=i + 1, - ) - - fig.add_trace( - go.Scatter( - x=dif_h_rad_month_ave.loc[ - dif_h_rad_month_ave["month"] == i + 1, "hour" - ], - y=dif_h_rad_month_ave.loc[ - dif_h_rad_month_ave["month"] == i + 1, "dif_hor_rad" - ], - fill="tozeroy", - mode="lines", - line_color="dodgerblue", - line_width=2, - name="Diffuse", - showlegend=is_first, - customdata=epw_df.loc[epw_df["month"] == i + 1, "month_names"], - hovertemplate=( - "" - + "Diffuse Horizontal Solar Radiation" - + ": %{y:.2f} " - + mapping_dictionary["dif_hor_rad"][si_ip]["unit"] - + "
" - + "Month: %{customdata}
" - + "Hour: %{x}:00
" - + "" # Hides the "secondary box" + row=1, + col=col_idx, + ) + if not legend_shown["Global"]: + legend_shown["Global"] = True + + month_dif_unfiltered = dif_h_rad_month_ave.loc[ + dif_h_rad_month_ave[Variables.MONTH.col_name] == month_num + ] + if len(month_dif_unfiltered) > 0: + fig.add_trace( + go.Scatter( + x=month_dif_unfiltered[Variables.HOUR.col_name], + y=month_dif_unfiltered[Variables.DIF_HOR_RAD.col_name], + fill="tozeroy", + mode="lines", + line_color="dodgerblue", + line_width=2, + name="Diffuse", + legendgroup="Diffuse", + showlegend=not legend_shown["Diffuse"], + customdata=[month_lst[month_num - 1]] * len(month_dif_unfiltered), + hovertemplate=( + "" + + "Diffuse Horizontal Solar Radiation" + + ": %{y:.2f} " + + VariableInfo.from_col_name( + Variables.DIF_HOR_RAD.col_name + ).get_unit(si_ip) + + "
" + + "Month: %{customdata}
" + + "Hour: %{x}:00
" + + "" # Hides the "secondary box" + ), ), - ), - row=1, - col=i + 1, - ) + row=1, + col=col_idx, + ) + if not legend_shown["Diffuse"]: + legend_shown["Diffuse"] = True - fig.update_xaxes(range=[0, 25], row=1, col=i + 1) + fig.update_xaxes(range=[0, 25], row=1, col=col_idx) if si_ip == UnitSystem.SI: fig.update_yaxes(range=[0, 1000]) @@ -109,23 +262,47 @@ def monthly_solar(epw_df, si_ip): def polar_graph(df, meta, global_local, var, si_ip): """Return the figure for the custom sun path.""" - latitude = float(meta["lat"]) - longitude = float(meta["lon"]) - time_zone = float(meta["time_zone"]) - solpos = df.loc[df["apparent_elevation"] > 0, :] + latitude = float(meta[Variables.LAT.col_name]) + longitude = float(meta[Variables.LON.col_name]) + time_zone = float(meta[Variables.TIME_ZONE.col_name]) + + # Separate filtered and unfiltered data using utility function + # Note: For "None" variable, pass None to avoid checking for original column + filter_var = None if var == "None" else var + filter_info = separate_filtered_data(df, filter_var) + df_unfiltered = filter_info["df_unfiltered"] + df_filtered = filter_info["df_filtered"] + original_var_col = filter_info["original_var_col"] + use_original_for_filtered = filter_info["use_original_for_filtered"] + # Adjust for "None" case + if var == "None": + original_var_col = None + use_original_for_filtered = False + + solpos_unfiltered = df_unfiltered.loc[ + df_unfiltered[Variables.APPARENT_ELEVATION.col_name] > 0, : + ] + solpos_filtered = None + if df_filtered is not None and len(df_filtered) > 0: + solpos_filtered = df_filtered.loc[ + df_filtered[Variables.APPARENT_ELEVATION.col_name] > 0, : + ] if var != "None": - var_unit = mapping_dictionary[var][si_ip]["unit"] - var_range = mapping_dictionary[var][si_ip]["range"] - var_name = mapping_dictionary[var]["name"] - var_color = mapping_dictionary[var]["color"] + variable = VariableInfo.from_col_name(var) + var_unit = variable.get_unit(si_ip) + var_range = variable.get_range(si_ip) + var_name = variable.get_name() + var_color = variable.get_color() if global_local == "global": # Set Global values for Max and minimum range_z = var_range else: # Set maximum and minimum according to data - data_max = 5 * ceil(solpos[var].max() / 5) - data_min = 5 * floor(solpos[var].min() / 5) + if len(solpos_unfiltered) > 0: + data_max, data_min = get_max_min_value(solpos_unfiltered[var]) + else: + data_max, data_min = var_range range_z = [data_min, data_max] tz = "UTC" @@ -134,14 +311,16 @@ def polar_graph(df, meta, global_local, var, si_ip): ) delta = timedelta(days=0, hours=time_zone - 1, minutes=0) times = times - delta - solpos = df.loc[df["apparent_elevation"] > 0, :] if var == "None": var_color = "orange" marker_size = 3 else: - vals = solpos[var] - marker_size = ((vals - vals.min()) / (vals.max() - vals.min()) + 1) * 4 + if len(solpos_unfiltered) > 0: + vals = solpos_unfiltered[var] + marker_size = ((vals - vals.min()) / (vals.max() - vals.min()) + 1) * 4 + else: + marker_size = 3 fig = go.Figure() # draw altitude circles @@ -161,86 +340,192 @@ def polar_graph(df, meta, global_local, var, si_ip): name="", ) ) - # Draw annalemma - if var == "None": - fig.add_trace( - go.Scatterpolar( - r=90 * np.cos(np.radians(90 - solpos["apparent_zenith"])), - theta=solpos["azimuth"], - mode="markers", - marker_color="orange", - marker_size=marker_size, - marker_line_width=0, - customdata=np.stack( - ( - solpos["day"], - solpos["month_names"], - solpos["hour"], - solpos["elevation"], - solpos["azimuth"], + + # Draw filtered data (gray) if any + if solpos_filtered is not None and len(solpos_filtered) > 0: + if var == "None": + fig.add_trace( + go.Scatterpolar( + r=90 + * np.cos( + np.radians( + 90 - solpos_filtered[Variables.APPARENT_ZENITH.col_name] + ) ), - axis=-1, - ), - hovertemplate="month: %{customdata[1]}" - + "
day: %{customdata[0]:.0f}" - + "
hour: %{customdata[2]:.0f}:00" - + "
sun altitude: %{customdata[3]:.2f}" - + degrees_unit - + "
sun azimuth: %{customdata[4]:.2f}" - + degrees_unit - + "
", - name="", + theta=solpos_filtered[Variables.AZIMUTH.col_name], + mode="markers", + marker_color="gray", + marker_size=marker_size * 0.7, + marker_opacity=0.5, + marker_line_width=0, + customdata=np.stack( + ( + solpos_filtered[Variables.DAY.col_name], + solpos_filtered[Variables.MONTH_NAMES.col_name], + solpos_filtered[Variables.HOUR.col_name], + solpos_filtered[Variables.ELEVATION.col_name], + solpos_filtered[Variables.AZIMUTH.col_name], + ), + axis=-1, + ), + hovertemplate="Filtered Data
month: %{customdata[1]}" + + "
day: %{customdata[0]:.0f}" + + "
hour: %{customdata[2]:.0f}:00" + + "
sun altitude: %{customdata[3]:.2f}" + + degrees_unit + + "
sun azimuth: %{customdata[4]:.2f}" + + degrees_unit + + "
", + name="Filtered", + ) ) - ) - else: - fig.add_trace( - go.Scatterpolar( - r=90 * np.cos(np.radians(90 - solpos["apparent_zenith"])), - theta=solpos["azimuth"], - mode="markers", - marker=dict( - color=solpos[var], - size=marker_size, - line_width=0, - colorscale=var_color, - cmin=range_z[0], - cmax=range_z[1], - colorbar=dict(thickness=30, title=var_unit + "
"), - ), - customdata=np.stack( - ( - solpos["day"], - solpos["month_names"], - solpos["hour"], - solpos["elevation"], - solpos["azimuth"], - solpos[var], + else: + # For filtered data with variable, use original values + if use_original_for_filtered: + filtered_var_vals = solpos_filtered[original_var_col] + else: + filtered_var_vals = solpos_filtered[var] + + fig.add_trace( + go.Scatterpolar( + r=90 + * np.cos( + np.radians( + 90 - solpos_filtered[Variables.APPARENT_ZENITH.col_name] + ) ), - axis=-1, - ), - hovertemplate="month: %{customdata[1]}" - + "
day: %{customdata[0]:.0f}" - + "
hour: %{customdata[2]:.0f}:00" - + "
sun altitude: %{customdata[3]:.2f}" - + degrees_unit - + "
sun azimuth: %{customdata[4]:.2f}" - + degrees_unit - + "
" - + "
" - + var_name - + ": %{customdata[5]:.2f}" - + var_unit - + "", - name="", + theta=solpos_filtered[Variables.AZIMUTH.col_name], + mode="markers", + marker=dict( + color="gray", + size=marker_size * 0.7, + opacity=0.5, + line_width=0, + ), + customdata=np.stack( + ( + solpos_filtered[Variables.DAY.col_name], + solpos_filtered[Variables.MONTH_NAMES.col_name], + solpos_filtered[Variables.HOUR.col_name], + solpos_filtered[Variables.ELEVATION.col_name], + solpos_filtered[Variables.AZIMUTH.col_name], + filtered_var_vals, + ), + axis=-1, + ), + hovertemplate="Filtered Data
month: %{customdata[1]}" + + "
day: %{customdata[0]:.0f}" + + "
hour: %{customdata[2]:.0f}:00" + + "
sun altitude: %{customdata[3]:.2f}" + + degrees_unit + + "
sun azimuth: %{customdata[4]:.2f}" + + degrees_unit + + "
" + + "
" + + var_name + + ": %{customdata[5]:.2f}" + + var_unit + + "", + name="Filtered", + ) + ) + + # Draw unfiltered data (normal colors) + if len(solpos_unfiltered) > 0: + if var == "None": + fig.add_trace( + go.Scatterpolar( + r=90 + * np.cos( + np.radians( + 90 - solpos_unfiltered[Variables.APPARENT_ZENITH.col_name] + ) + ), + theta=solpos_unfiltered[Variables.AZIMUTH.col_name], + mode="markers", + marker_color="orange", + marker_size=marker_size, + marker_line_width=0, + customdata=np.stack( + ( + solpos_unfiltered[Variables.DAY.col_name], + solpos_unfiltered[Variables.MONTH_NAMES.col_name], + solpos_unfiltered[Variables.HOUR.col_name], + solpos_unfiltered[Variables.ELEVATION.col_name], + solpos_unfiltered[Variables.AZIMUTH.col_name], + ), + axis=-1, + ), + hovertemplate="month: %{customdata[1]}" + + "
day: %{customdata[0]:.0f}" + + "
hour: %{customdata[2]:.0f}:00" + + "
sun altitude: %{customdata[3]:.2f}" + + degrees_unit + + "
sun azimuth: %{customdata[4]:.2f}" + + degrees_unit + + "
", + name="", + ) + ) + else: + fig.add_trace( + go.Scatterpolar( + r=90 + * np.cos( + np.radians( + 90 - solpos_unfiltered[Variables.APPARENT_ZENITH.col_name] + ) + ), + theta=solpos_unfiltered[Variables.AZIMUTH.col_name], + mode="markers", + marker=dict( + color=solpos_unfiltered[var], + size=marker_size, + line_width=0, + colorscale=var_color, + cmin=range_z[0], + cmax=range_z[1], + colorbar=dict(thickness=30, title=var_unit + "
"), + ), + customdata=np.stack( + ( + solpos_unfiltered[Variables.DAY.col_name], + solpos_unfiltered[Variables.MONTH_NAMES.col_name], + solpos_unfiltered[Variables.HOUR.col_name], + solpos_unfiltered[Variables.ELEVATION.col_name], + solpos_unfiltered[Variables.AZIMUTH.col_name], + solpos_unfiltered[var], + ), + axis=-1, + ), + hovertemplate="month: %{customdata[1]}" + + "
day: %{customdata[0]:.0f}" + + "
hour: %{customdata[2]:.0f}:00" + + "
sun altitude: %{customdata[3]:.2f}" + + degrees_unit + + "
sun azimuth: %{customdata[4]:.2f}" + + degrees_unit + + "
" + + "
" + + var_name + + ": %{customdata[5]:.2f}" + + var_unit + + "", + name="", + ) ) - ) # draw equinox and sostices for date in pd.to_datetime(["2019-03-21", "2019-06-21", "2019-12-21"]): - times = pd.date_range(date, date + pd.Timedelta("24h"), freq="5min", tz=tz) + times = pd.date_range( + date, + date + pd.Timedelta(Variables.TWENTY_FOUR_HOUR.col_name), + freq=Variables.FIVE_MINUTE.col_name, + tz=tz, + ) times = times - delta solpos = solarposition.get_solarposition(times, latitude, longitude) - solpos = solpos.loc[solpos["apparent_elevation"] > 0, :] + solpos = solpos.loc[solpos[Variables.APPARENT_ELEVATION.col_name] > 0, :] fig.add_trace( go.Scatterpolar( @@ -261,10 +546,15 @@ def polar_graph(df, meta, global_local, var, si_ip): # draw sunpath on the 21st of each other month for date in pd.to_datetime(["2019-01-21", "2019-02-21", "2019-4-21", "2019-5-21"]): - times = pd.date_range(date, date + pd.Timedelta("24h"), freq="5min", tz=tz) + times = pd.date_range( + date, + date + pd.Timedelta(Variables.TWENTY_FOUR_HOUR.col_name), + freq=Variables.FIVE_MINUTE.col_name, + tz=tz, + ) times = times - delta solpos = solarposition.get_solarposition(times, latitude, longitude) - solpos = solpos.loc[solpos["apparent_elevation"] > 0, :] + solpos = solpos.loc[solpos[Variables.APPARENT_ELEVATION.col_name] > 0, :] fig.add_trace( go.Scatterpolar( @@ -312,115 +602,219 @@ def polar_graph(df, meta, global_local, var, si_ip): def custom_cartesian_solar(df, meta, global_local, var, si_ip): """Return a graph of a latitude and longitude solar diagram.""" - latitude = float(meta["lat"]) - longitude = float(meta["lon"]) - time_zone = float(meta["time_zone"]) + latitude = float(meta[Variables.LAT.col_name]) + longitude = float(meta[Variables.LON.col_name]) + time_zone = float(meta[Variables.TIME_ZONE.col_name]) tz = "UTC" + # Separate filtered and unfiltered data using utility function + # Note: For "None" variable, pass None to avoid checking for original column + filter_var = None if var == "None" else var + filter_info = separate_filtered_data(df, filter_var) + df_unfiltered = filter_info["df_unfiltered"] + df_filtered = filter_info["df_filtered"] + original_var_col = filter_info["original_var_col"] + use_original_for_filtered = filter_info["use_original_for_filtered"] + # Adjust for "None" case + if var == "None": + original_var_col = None + use_original_for_filtered = False + + variable = VariableInfo.from_col_name(var) if var != "None": - var_unit = mapping_dictionary[var][si_ip]["unit"] - var_range = mapping_dictionary[var][si_ip]["range"] - var_name = mapping_dictionary[var]["name"] - var_color = mapping_dictionary[var]["color"] + var_unit = variable.get_unit(si_ip) + var_range = variable.get_range(si_ip) + var_name = variable.get_name() + var_color = variable.get_color() if global_local == "global": # Set Global values for Max and minimum range_z = var_range else: # Set maximum and minimum according to data - data_max = 5 * ceil(df[var].max() / 5) - data_min = 5 * floor(df[var].min() / 5) + if len(df_unfiltered) > 0: + data_max, data_min = get_max_min_value(df_unfiltered[var]) + else: + data_max, data_min = var_range range_z = [data_min, data_max] if var == "None": var_color = "orange" marker_size = 3 else: - vals = df[var] - marker_size = ((vals - vals.min()) / (vals.max() - vals.min()) + 1) * 4 + if len(df_unfiltered) > 0: + vals = df_unfiltered[var] + marker_size = ((vals - vals.min()) / (vals.max() - vals.min()) + 1) * 4 + else: + marker_size = 3 fig = go.Figure() - # draw annalemma - if var == "None": - fig.add_trace( - go.Scatter( - y=df["elevation"], - x=df["azimuth"], - mode="markers", - marker_color="orange", - marker_size=marker_size, - marker_line_width=0, - customdata=np.stack( - ( - df["day"], - df["month_names"], - df["hour"], - df["elevation"], - df["azimuth"], + # Draw filtered data (gray) if any + if df_filtered is not None and len(df_filtered) > 0: + if var == "None": + fig.add_trace( + go.Scatter( + y=df_filtered[Variables.ELEVATION.col_name], + x=df_filtered[Variables.AZIMUTH.col_name], + mode="markers", + marker_color="gray", + marker_size=marker_size * 0.7, + marker_opacity=0.5, + marker_line_width=0, + customdata=np.stack( + ( + df_filtered[Variables.DAY.col_name], + df_filtered[Variables.MONTH_NAMES.col_name], + df_filtered[Variables.HOUR.col_name], + df_filtered[Variables.ELEVATION.col_name], + df_filtered[Variables.AZIMUTH.col_name], + ), + axis=-1, ), - axis=-1, - ), - hovertemplate="month: %{customdata[1]}" - + "
day: %{customdata[0]:.0f}" - + "
hour: %{customdata[2]:.0f}:00" - + "
sun altitude: %{customdata[3]:.2f}" - + degrees_unit - + "
sun azimuth: %{customdata[4]:.2f}" - + degrees_unit - + "
", - name="", + hovertemplate="Filtered Data
month: %{customdata[1]}" + + "
day: %{customdata[0]:.0f}" + + "
hour: %{customdata[2]:.0f}:00" + + "
sun altitude: %{customdata[3]:.2f}" + + degrees_unit + + "
sun azimuth: %{customdata[4]:.2f}" + + degrees_unit + + "
", + name="Filtered", + ) ) - ) - else: - fig.add_trace( - go.Scatter( - y=df["elevation"], - x=df["azimuth"], - mode="markers", - marker=dict( - color=df[var], - size=marker_size, - line_width=0, - colorscale=var_color, - cmin=range_z[0], - cmax=range_z[1], - colorbar=dict(thickness=30, title=var_unit + "
"), - ), - customdata=np.stack( - ( - df["day"], - df["month_names"], - df["hour"], - df["elevation"], - df["azimuth"], - df[var], + else: + # For filtered data with variable, use original values + if use_original_for_filtered: + filtered_var_vals = df_filtered[original_var_col] + else: + filtered_var_vals = df_filtered[var] + + fig.add_trace( + go.Scatter( + y=df_filtered[Variables.ELEVATION.col_name], + x=df_filtered[Variables.AZIMUTH.col_name], + mode="markers", + marker=dict( + color="gray", + size=marker_size * 0.7, + opacity=0.5, + line_width=0, ), - axis=-1, - ), - hovertemplate="month: %{customdata[1]}" - + "
day: %{customdata[0]:.0f}" - + "
hour: %{customdata[2]:.0f}:00" - + "
sun altitude: %{customdata[3]:.2f}" - + degrees_unit - + "
sun azimuth: %{customdata[4]:.2f}" - + degrees_unit - + "
" - + "
" - + var_name - + ": %{customdata[5]:.2f}" - + var_unit - + "", - name="", + customdata=np.stack( + ( + df_filtered[Variables.DAY.col_name], + df_filtered[Variables.MONTH_NAMES.col_name], + df_filtered[Variables.HOUR.col_name], + df_filtered[Variables.ELEVATION.col_name], + df_filtered[Variables.AZIMUTH.col_name], + filtered_var_vals, + ), + axis=-1, + ), + hovertemplate="Filtered Data
month: %{customdata[1]}" + + "
day: %{customdata[0]:.0f}" + + "
hour: %{customdata[2]:.0f}:00" + + "
sun altitude: %{customdata[3]:.2f}" + + degrees_unit + + "
sun azimuth: %{customdata[4]:.2f}" + + degrees_unit + + "
" + + "
" + + var_name + + ": %{customdata[5]:.2f}" + + var_unit + + "", + name="Filtered", + ) + ) + + # Draw unfiltered data (normal colors) + if len(df_unfiltered) > 0: + if var == "None": + fig.add_trace( + go.Scatter( + y=df_unfiltered[Variables.ELEVATION.col_name], + x=df_unfiltered[Variables.AZIMUTH.col_name], + mode="markers", + marker_color="orange", + marker_size=marker_size, + marker_line_width=0, + customdata=np.stack( + ( + df_unfiltered[Variables.DAY.col_name], + df_unfiltered[Variables.MONTH_NAMES.col_name], + df_unfiltered[Variables.HOUR.col_name], + df_unfiltered[Variables.ELEVATION.col_name], + df_unfiltered[Variables.AZIMUTH.col_name], + ), + axis=-1, + ), + hovertemplate="month: %{customdata[1]}" + + "
day: %{customdata[0]:.0f}" + + "
hour: %{customdata[2]:.0f}:00" + + "
sun altitude: %{customdata[3]:.2f}" + + degrees_unit + + "
sun azimuth: %{customdata[4]:.2f}" + + degrees_unit + + "
", + name="", + ) + ) + else: + fig.add_trace( + go.Scatter( + y=df_unfiltered[Variables.ELEVATION.col_name], + x=df_unfiltered[Variables.AZIMUTH.col_name], + mode="markers", + marker=dict( + color=df_unfiltered[var], + size=marker_size, + line_width=0, + colorscale=var_color, + cmin=range_z[0], + cmax=range_z[1], + colorbar=dict(thickness=30, title=var_unit + "
"), + ), + customdata=np.stack( + ( + df_unfiltered[Variables.DAY.col_name], + df_unfiltered[Variables.MONTH_NAMES.col_name], + df_unfiltered[Variables.HOUR.col_name], + df_unfiltered[Variables.ELEVATION.col_name], + df_unfiltered[Variables.AZIMUTH.col_name], + df_unfiltered[var], + ), + axis=-1, + ), + hovertemplate="month: %{customdata[1]}" + + "
day: %{customdata[0]:.0f}" + + "
hour: %{customdata[2]:.0f}:00" + + "
sun altitude: %{customdata[3]:.2f}" + + degrees_unit + + "
sun azimuth: %{customdata[4]:.2f}" + + degrees_unit + + "
" + + "
" + + var_name + + ": %{customdata[5]:.2f}" + + var_unit + + "", + name="", + ) ) - ) # draw equinox and sostices for date in pd.to_datetime(["2019-03-21", "2019-06-21", "2019-12-21"]): - times = pd.date_range(date, date + pd.Timedelta("24h"), freq="5min", tz=tz) + times = pd.date_range( + date, + date + pd.Timedelta(Variables.TWENTY_FOUR_HOUR.col_name), + freq=Variables.FIVE_MINUTE.col_name, + tz=tz, + ) delta = timedelta(days=0, hours=time_zone - 1, minutes=0) times = times - delta solpos = solarposition.get_solarposition(times, latitude, longitude) - solpos = solpos.loc[solpos["apparent_elevation"] > 0, :] + solpos = solpos.loc[solpos[Variables.APPARENT_ELEVATION.col_name] > 0, :] fig.add_trace( go.Scatter( @@ -440,11 +834,16 @@ def custom_cartesian_solar(df, meta, global_local, var, si_ip): # draw sunpath on the 21st of each other month for date in pd.to_datetime(["2019-01-21", "2019-02-21", "2019-4-21", "2019-5-21"]): - times = pd.date_range(date, date + pd.Timedelta("24h"), freq="5min", tz=tz) + times = pd.date_range( + date, + date + pd.Timedelta(Variables.TWENTY_FOUR_HOUR.col_name), + freq=Variables.FIVE_MINUTE.col_name, + tz=tz, + ) delta = timedelta(days=0, hours=time_zone - 1, minutes=0) times = times - delta solpos = solarposition.get_solarposition(times, latitude, longitude) - solpos = solpos.loc[solpos["apparent_elevation"] > 0, :] + solpos = solpos.loc[solpos[Variables.APPARENT_ELEVATION.col_name] > 0, :] fig.add_trace( go.Scatter( diff --git a/pages/lib/extract_df.py b/pages/lib/extract_df.py index b030a3eb..75b8e06e 100644 --- a/pages/lib/extract_df.py +++ b/pages/lib/extract_df.py @@ -16,9 +16,10 @@ from pythermalcomfort.models import solar_gain from pythermalcomfort.models import utci from pythermalcomfort.utilities import running_mean_outdoor_temperature - +from config import UnitSystem from pages.lib.global_scheme import month_lst from pages.lib.utils import code_timer +from pages.lib.global_variables import Variables, VariableInfo @code_timer @@ -67,13 +68,79 @@ def get_location_info(lst, file_name): # from OneClimaBuilding files extract info about reference years try: - location_info["period"] = re.search(r'cord=[\'"]?([^\'" >]+);', lst[5]).group(1) + location_info[Variables.PERIOD.col_name] = re.search( + r'cord=[\'"]?([^\'" >]+);', lst[5] + ).group(1) except AttributeError: pass return location_info +# ==== Unified UTCI computation and binning ==== +UTCI_BINS = [-999, -40, -27, -13, 0, 9, 26, 32, 38, 46, 999] +UTCI_LABELS = [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4] + + +def utci_calc( + df: pd.DataFrame, + t_air_col: str, + t_rad_col: str, + wind_col: str, + rh_col: str = Variables.RH.col_name, +) -> pd.Series: + """Call utci() using values from df columns.""" + return utci(df[t_air_col], df[t_rad_col], df[wind_col], df[rh_col]) + + +def add_utci_variants(df: pd.DataFrame) -> pd.DataFrame: + """ + Generate the four UTCI variants: + - noSun_Wind : DBT + DBT + wind_speed_utci + - noSun_noWind : DBT + DBT + wind_speed_utci_0 + - Sun_Wind : DBT + MRT + wind_speed_utci + - Sun_noWind : DBT + MRT + wind_speed_utci_0 + """ + recipes = { + Variables.UTCI_NO_SUN_WIND.col_name: ( + Variables.DBT.col_name, + Variables.DBT.col_name, + Variables.WIND_SPEED_UTCI.col_name, + ), + Variables.UTCI_NO_SUN_NO_WIND.col_name: ( + Variables.DBT.col_name, + Variables.DBT.col_name, + Variables.WIND_SPEED_UTCI_0.col_name, + ), + Variables.UTCI_SUN_WIND.col_name: ( + Variables.DBT.col_name, + Variables.MRT.col_name, + Variables.WIND_SPEED_UTCI.col_name, + ), + Variables.UTCI_SUN_NO_WIND.col_name: ( + Variables.DBT.col_name, + Variables.MRT.col_name, + Variables.WIND_SPEED_UTCI_0.col_name, + ), + } + for out_col, (t_air, t_rad, wind) in recipes.items(): + df[out_col] = utci_calc(df, t_air, t_rad, wind) + return df + + +def add_utci_categories(df: pd.DataFrame) -> pd.DataFrame: + """Bin the four UTCI columns into categories.""" + mapping = { + Variables.UTCI_NO_SUN_WIND.col_name: Variables.UTCI_NOSUN_WIND_CATEGORIES.col_name, + Variables.UTCI_NO_SUN_NO_WIND.col_name: Variables.UTCI_NOSUN_NOWIND_CATEGORIES.col_name, + Variables.UTCI_SUN_WIND.col_name: Variables.UTCI_SUN_WIND_CATEGORIES.col_name, + Variables.UTCI_SUN_NO_WIND.col_name: Variables.UTCI_SUN_NOWIND_CATEGORIES.col_name, + } + for src_col, dst_col in mapping.items(): + df[dst_col] = pd.cut(x=df[src_col], bins=UTCI_BINS, labels=UTCI_LABELS) + return df + + @code_timer def create_df(lst, file_name): """Extract and clean the data. Return a pandas data from a url.""" @@ -93,7 +160,9 @@ def create_df(lst, file_name): # from OneClimaBuilding files extract info about reference years try: - location_info["period"] = re.search(r'cord=[\'"]?([^\'" >]+);', lst[5]).group(1) + location_info[Variables.PERIOD.col_name] = re.search( + r'cord=[\'"]?([^\'" >]+);', lst[5] + ).group(1) except AttributeError: pass @@ -109,35 +178,35 @@ def create_df(lst, file_name): del line[-1] col_names = [ - "year", - "month", - "day", - "hour", - "DBT", - "DPT", - "RH", - "p_atm", - "extr_hor_rad", - "hor_ir_rad", - "glob_hor_rad", - "dir_nor_rad", - "dif_hor_rad", - "glob_hor_ill", - "dir_nor_ill", - "dif_hor_ill", - "Zlumi", - "wind_dir", - "wind_speed", - "tot_sky_cover", - "Oskycover", - "Vis", - "Cheight", - "PWobs", - "PWcodes", - "Pwater", - "AsolOptD", - "SnowD", - "DaySSnow", + Variables.YEAR.col_name, + Variables.MONTH.col_name, + Variables.DAY.col_name, + Variables.HOUR.col_name, + Variables.DBT.col_name, + Variables.DPT.col_name, + Variables.RH.col_name, + Variables.P_ATM.col_name, + Variables.EXTR_HOR_RAD.col_name, + Variables.HOR_IR_RAD.col_name, + Variables.GLOB_HOR_RAD.col_name, + Variables.DIR_NOR_RAD.col_name, + Variables.DIF_HOR_RAD.col_name, + Variables.GLOB_HOR_ILL.col_name, + Variables.DIR_NOR_ILL.col_name, + Variables.DIF_HOR_ILL.col_name, + Variables.ZLUMI.col_name, + Variables.WIND_DIR.col_name, + Variables.WIND_SPEED.col_name, + Variables.TOT_SKY_COVER.col_name, + Variables.OPAQUE_SKY_COVER.col_name, + Variables.VIS.col_name, + Variables.CLOUD_HEIGHT.col_name, + Variables.PRECIPITATION_OBSERVATION.col_name, + Variables.PRECIPITATION_CODES.col_name, + Variables.PRECIPITATION_WATER.col_name, + Variables.AEROSOL_OPTICAL_DEPTH.col_name, + Variables.SNOW_DEPTH.col_name, + Variables.DAILY_SNOW.col_name, ] # assign column names, if fewer cols are there than supposed assign 9999 to that col @@ -149,61 +218,88 @@ def create_df(lst, file_name): epw_df = pd.DataFrame(columns=col_names, data=lst) # from EnergyPlus files extract info about reference years - if not location_info["period"]: - years = epw_df["year"].astype("int").unique() + if not location_info[Variables.PERIOD.col_name]: + years = epw_df[Variables.YEAR.col_name].astype("int").unique() if len(years) == 1: year_rounded_up = int(math.ceil(years[0] / 10.0)) * 10 - location_info["period"] = f"{year_rounded_up - 10}-{year_rounded_up}" + location_info[Variables.PERIOD.col_name] = ( + f"{year_rounded_up - 10}-{year_rounded_up}" + ) else: min_year = int(math.floor(min(years) / 10.0)) * 10 max_year = int(math.ceil(max(years) / 10.0)) * 10 - location_info["period"] = f"{min_year}-{max_year}" + location_info[Variables.PERIOD.col_name] = f"{min_year}-{max_year}" # Add fake_year - epw_df["fake_year"] = "year" + epw_df[Variables.FAKE_YEAR.col_name] = Variables.YEAR.col_name # Add in month names month_look_up = {ix + 1: month for ix, month in enumerate(month_lst)} - epw_df["month_names"] = epw_df["month"].astype("int").map(month_look_up) + epw_df[Variables.MONTH_NAMES.col_name] = ( + epw_df[Variables.MONTH.col_name].astype("int").map(month_look_up) + ) # Change to int type - epw_df[["year", "day", "month", "hour"]] = epw_df[ - ["year", "day", "month", "hour"] + epw_df[ + [ + Variables.YEAR.col_name, + Variables.DAY.col_name, + Variables.MONTH.col_name, + Variables.HOUR.col_name, + ] + ] = epw_df[ + [ + Variables.YEAR.col_name, + Variables.DAY.col_name, + Variables.MONTH.col_name, + Variables.HOUR.col_name, + ] ].astype(int) # Add in DOY - df_doy = epw_df.groupby(["month", "day"])["hour"].count().reset_index() - df_doy["DOY"] = df_doy.index + 1 + df_doy = ( + epw_df.groupby([Variables.MONTH.col_name, Variables.DAY.col_name])[ + Variables.HOUR.col_name + ] + .count() + .reset_index() + ) + df_doy[Variables.DOY.col_name] = df_doy.index + 1 epw_df = pd.merge( - epw_df, df_doy[["month", "day", "DOY"]], on=["month", "day"], how="left" + epw_df, + df_doy[ + [Variables.MONTH.col_name, Variables.DAY.col_name, Variables.DOY.col_name] + ], + on=[Variables.MONTH.col_name, Variables.DAY.col_name], + how="left", ) change_to_float = [ - "DBT", - "DPT", - "RH", - "p_atm", - "extr_hor_rad", - "hor_ir_rad", - "glob_hor_rad", - "dir_nor_rad", - "dif_hor_rad", - "glob_hor_ill", - "dir_nor_ill", - "dif_hor_ill", - "Zlumi", - "wind_dir", - "wind_speed", - "tot_sky_cover", - "Oskycover", - "Vis", - "Cheight", - "PWobs", - "PWcodes", - "Pwater", - "AsolOptD", - "SnowD", - "DaySSnow", + Variables.DBT.col_name, + Variables.DPT.col_name, + Variables.RH.col_name, + Variables.P_ATM.col_name, + Variables.EXTR_HOR_RAD.col_name, + Variables.HOR_IR_RAD.col_name, + Variables.GLOB_HOR_RAD.col_name, + Variables.DIR_NOR_RAD.col_name, + Variables.DIF_HOR_RAD.col_name, + Variables.GLOB_HOR_ILL.col_name, + Variables.DIR_NOR_ILL.col_name, + Variables.DIF_HOR_ILL.col_name, + Variables.ZLUMI.col_name, + Variables.WIND_DIR.col_name, + Variables.WIND_SPEED.col_name, + Variables.TOT_SKY_COVER.col_name, + Variables.OPAQUE_SKY_COVER.col_name, + Variables.VIS.col_name, + Variables.CLOUD_HEIGHT.col_name, + Variables.PRECIPITATION_OBSERVATION.col_name, + Variables.PRECIPITATION_CODES.col_name, + Variables.PRECIPITATION_WATER.col_name, + Variables.AEROSOL_OPTICAL_DEPTH.col_name, + Variables.SNOW_DEPTH.col_name, + Variables.DAILY_SNOW.col_name, ] epw_df[change_to_float] = epw_df[change_to_float].astype(float) @@ -211,30 +307,40 @@ def create_df(lst, file_name): times = pd.date_range( "2019-01-01 00:00:00", "2020-01-01", inclusive="left", freq="h", tz="UTC" ) - epw_df["UTC_time"] = pd.to_datetime(times) - delta = timedelta(days=0, hours=location_info["time_zone"] - 1, minutes=0) + epw_df[Variables.UTC_TIME.col_name] = pd.to_datetime(times) + delta = timedelta( + days=0, hours=location_info[Variables.TIME_ZONE.col_name] - 1, minutes=0 + ) times = times - delta - epw_df["times"] = times + epw_df[Variables.TIMES.col_name] = times epw_df.set_index( - "times", drop=False, append=False, inplace=True, verify_integrity=False + Variables.TIMES.col_name, + drop=False, + append=False, + inplace=True, + verify_integrity=False, ) # Add in solar position df solar_position = solarposition.get_solarposition( - times, location_info["lat"], location_info["lon"] + times, + location_info[Variables.LAT.col_name], + location_info[Variables.LON.col_name], ) epw_df = pd.concat([epw_df, solar_position], axis=1) # Add in UTCI - sol_altitude = epw_df["elevation"].mask(epw_df["elevation"] <= 0, 0) - sharp = [45] * 8760 - sol_radiation_dir = epw_df["dir_nor_rad"] - sol_transmittance = [1] * 8760 # CHECK VALUE - f_svv = [1] * 8760 # CHECK VALUE - f_bes = [1] * 8760 # CHECK VALUE - asw = [0.7] * 8760 # CHECK VALUE - posture = ["standing"] * 8760 - floor_reflectance = [0.6] * 8760 # EXPOSE AS A VARIABLE? + sol_altitude = epw_df[Variables.ELEVATION.col_name].mask( + epw_df[Variables.ELEVATION.col_name] <= 0, 0 + ) + sharp = expand_to_hours(45) + sol_radiation_dir = epw_df[Variables.DIR_NOR_RAD.col_name] + sol_transmittance = expand_to_hours(1) # CHECK VALUE + f_svv = expand_to_hours(1) # CHECK VALUE + f_bes = expand_to_hours(1) # CHECK VALUE + asw = expand_to_hours(0.7) # CHECK VALUE + posture = expand_to_hours("standing") + floor_reflectance = expand_to_hours(0.6) # EXPOSE AS A VARIABLE? mrt = np.vectorize(solar_gain)( sol_altitude, @@ -248,65 +354,52 @@ def create_df(lst, file_name): floor_reflectance, ) mrt_df = pd.DataFrame.from_records(mrt) - mrt_df["delta_mrt"] = mrt_df["delta_mrt"].mask(mrt_df["delta_mrt"] >= 70, 70) + mrt_df[Variables.DELTA_MRT.col_name] = mrt_df[Variables.DELTA_MRT.col_name].mask( + mrt_df[Variables.DELTA_MRT.col_name] >= 70, 70 + ) mrt_df = mrt_df.set_index(epw_df.times) epw_df = epw_df.join(mrt_df) - epw_df["MRT"] = epw_df["delta_mrt"] + epw_df["DBT"] - epw_df["wind_speed_utci"] = epw_df["wind_speed"] - epw_df["wind_speed_utci"] = epw_df["wind_speed_utci"].mask( - epw_df["wind_speed_utci"] >= 17, 16.9 - ) - epw_df["wind_speed_utci"] = epw_df["wind_speed_utci"].mask( - epw_df["wind_speed_utci"] <= 0.5, 0.6 - ) - epw_df["wind_speed_utci_0"] = epw_df["wind_speed_utci"].mask( - epw_df["wind_speed_utci"] >= 0, 0.5 - ) - epw_df["utci_noSun_Wind"] = utci( - epw_df["DBT"], epw_df["DBT"], epw_df["wind_speed_utci"], epw_df["RH"] - ) - epw_df["utci_noSun_noWind"] = utci( - epw_df["DBT"], epw_df["DBT"], epw_df["wind_speed_utci_0"], epw_df["RH"] - ) - epw_df["utci_Sun_Wind"] = utci( - epw_df["DBT"], epw_df["MRT"], epw_df["wind_speed_utci"], epw_df["RH"] - ) - epw_df["utci_Sun_noWind"] = utci( - epw_df["DBT"], epw_df["MRT"], epw_df["wind_speed_utci_0"], epw_df["RH"] + epw_df[Variables.MRT.col_name] = ( + epw_df[Variables.DELTA_MRT.col_name] + epw_df[Variables.DBT.col_name] ) + epw_df[Variables.WIND_SPEED_UTCI.col_name] = epw_df[Variables.WIND_SPEED.col_name] + epw_df[Variables.WIND_SPEED_UTCI.col_name] = epw_df[ + Variables.WIND_SPEED_UTCI.col_name + ].mask(epw_df[Variables.WIND_SPEED_UTCI.col_name] >= 17, 16.9) + epw_df[Variables.WIND_SPEED_UTCI.col_name] = epw_df[ + Variables.WIND_SPEED_UTCI.col_name + ].mask(epw_df[Variables.WIND_SPEED_UTCI.col_name] <= 0.5, 0.6) + epw_df[Variables.WIND_SPEED_UTCI_0.col_name] = epw_df[ + Variables.WIND_SPEED_UTCI.col_name + ].mask(epw_df[Variables.WIND_SPEED_UTCI.col_name] >= 0, 0.5) - utci_bins = [-999, -40, -27, -13, 0, 9, 26, 32, 38, 46, 999] - utci_labels = [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4] - epw_df["utci_noSun_Wind_categories"] = pd.cut( - x=epw_df["utci_noSun_Wind"], bins=utci_bins, labels=utci_labels - ) - epw_df["utci_noSun_noWind_categories"] = pd.cut( - x=epw_df["utci_noSun_noWind"], bins=utci_bins, labels=utci_labels - ) - epw_df["utci_Sun_Wind_categories"] = pd.cut( - x=epw_df["utci_Sun_Wind"], bins=utci_bins, labels=utci_labels - ) - epw_df["utci_Sun_noWind_categories"] = pd.cut( - x=epw_df["utci_Sun_noWind"], bins=utci_bins, labels=utci_labels - ) + epw_df = add_utci_variants(epw_df) + + epw_df = add_utci_categories(epw_df) # Add psy values - ta_rh = np.vectorize(psy.psy_ta_rh)(epw_df["DBT"], epw_df["RH"]) + ta_rh = np.vectorize(psy.psy_ta_rh)( + epw_df[Variables.DBT.col_name], epw_df[Variables.RH.col_name] + ) psy_df = pd.DataFrame.from_records(ta_rh) psy_df = psy_df.set_index(epw_df.times) epw_df = epw_df.join(psy_df) # calculate adaptive data - dbt_day_ave = epw_df.groupby(["DOY"])["DBT"].mean().to_list() + dbt_day_ave = ( + epw_df.groupby([Variables.DOY.col_name])[Variables.DBT.col_name] + .mean() + .to_list() + ) n = 7 - epw_df["adaptive_comfort"] = np.nan - epw_df["adaptive_cmf_80_low"] = np.nan - epw_df["adaptive_cmf_80_up"] = np.nan - epw_df["adaptive_cmf_90_low"] = np.nan - epw_df["adaptive_cmf_90_up"] = np.nan - epw_df["adaptive_cmf_rmt"] = np.nan + epw_df[Variables.ADAPTIVE_COMFORT.col_name] = np.nan + epw_df[Variables.ADAPTIVE_CMF_80_LOW.col_name] = np.nan + epw_df[Variables.ADAPTIVE_CMF_80_UP.col_name] = np.nan + epw_df[Variables.ADAPTIVE_CMF_90_LOW.col_name] = np.nan + epw_df[Variables.ADAPTIVE_CMF_90_UP.col_name] = np.nan + epw_df[Variables.ADAPTIVE_CMF_RMT.col_name] = np.nan for day in epw_df.DOY.unique(): i = day - 1 if i < n: @@ -327,64 +420,125 @@ def create_df(lst, file_name): v=0.5, limit_inputs=False, ) - epw_df.loc[epw_df.DOY == day, "adaptive_cmf_rmt"] = rmt - epw_df.loc[epw_df.DOY == day, "adaptive_comfort"] = r["tmp_cmf"] - epw_df.loc[epw_df.DOY == day, "adaptive_cmf_80_low"] = r["tmp_cmf_80_low"] - epw_df.loc[epw_df.DOY == day, "adaptive_cmf_80_up"] = r["tmp_cmf_80_up"] - epw_df.loc[epw_df.DOY == day, "adaptive_cmf_90_low"] = r["tmp_cmf_90_low"] - epw_df.loc[epw_df.DOY == day, "adaptive_cmf_90_up"] = r["tmp_cmf_90_up"] + epw_df.loc[epw_df.DOY == day, Variables.ADAPTIVE_CMF_RMT.col_name] = rmt + epw_df.loc[epw_df.DOY == day, Variables.ADAPTIVE_COMFORT.col_name] = r[ + Variables.TMP_CMF.col_name + ] + epw_df.loc[epw_df.DOY == day, Variables.ADAPTIVE_CMF_80_LOW.col_name] = r[ + Variables.TMP_CMF_80_LOW.col_name + ] + epw_df.loc[epw_df.DOY == day, Variables.ADAPTIVE_CMF_80_UP.col_name] = r[ + Variables.TMP_CMF_80_UP.col_name + ] + epw_df.loc[epw_df.DOY == day, Variables.ADAPTIVE_CMF_90_LOW.col_name] = r[ + Variables.TMP_CMF_90_LOW.col_name + ] + epw_df.loc[epw_df.DOY == day, Variables.ADAPTIVE_CMF_90_UP.col_name] = r[ + Variables.TMP_CMF_90_UP.col_name + ] return epw_df, location_info # convert function -def temperature(df, name): - df[name] = df[name] * 1.8 + 32 - - -def pressure(df, name): - df[name] = df[name] * 0.000145038 - - -def irradiation(df, name): - df[name] = df[name] * 0.3169983306 - - -def illuminance(df, name): - df[name] = df[name] * 0.0929 +def convert_SI_to_IP(df: pd.DataFrame, name: str) -> None: + """Convert SI to IP based on column name.""" + if name not in df.columns: + return + + match name: + case ( + Variables.DBT.col_name + | Variables.DPT.col_name + | Variables.T_WB.col_name + | Variables.T_DP.col_name + | Variables.UTCI_SUN_WIND.col_name + | Variables.UTCI_NO_SUN_WIND.col_name + | Variables.UTCI_SUN_NO_WIND.col_name + | Variables.UTCI_NO_SUN_NO_WIND.col_name + | Variables.ADAPTIVE_COMFORT.col_name + | Variables.ADAPTIVE_CMF_80_LOW.col_name + | Variables.ADAPTIVE_CMF_80_UP.col_name + | Variables.ADAPTIVE_CMF_90_LOW.col_name + | Variables.ADAPTIVE_CMF_90_UP.col_name + ): + df[name] = df[name] * 1.8 + 32 + + case ( + Variables.P_ATM.col_name + | Variables.P_VAP.col_name + | Variables.P_SAT.col_name + ): + df[name] = df[name] * 0.000145038 + + case ( + Variables.EXTR_HOR_RAD.col_name + | Variables.HOR_IR_RAD.col_name + | Variables.GLOB_HOR_RAD.col_name + | Variables.DIR_NOR_RAD.col_name + | Variables.DIF_HOR_RAD.col_name + ): + df[name] = df[name] * 0.3169983306 + + case ( + Variables.GLOB_HOR_ILL.col_name + | Variables.DIR_NOR_ILL.col_name + | Variables.DIF_HOR_ILL.col_name + ): + df[name] = df[name] * 0.0929 + + case Variables.ZLUMI.col_name: + df[name] = df[name] * 0.0929 + + case Variables.WIND_SPEED.col_name: + df[name] = df[name] * 196.85039370078738 + + case Variables.VIS.col_name: + df[name] = df[name] * 0.6215 + + case Variables.EH.col_name: + df[name] = df[name] * 0.000429923 + + case _: + # No conversion needed for this column + pass + + +def convert_df_units(df: pd.DataFrame, unit_system: str) -> pd.DataFrame: + """Convert DataFrame columns to the specified unit system.""" + if unit_system != UnitSystem.IP: + return df # Currently we only support SI → IP + + df_converted = df.copy() + + for attr in dir(Variables): + if attr.isupper(): + var = getattr(Variables, attr) + if isinstance(var, VariableInfo): + col = var.col_name + convert_SI_to_IP(df_converted, col) + + return df_converted -def zenith_illuminance(df, name): - df[name] = df[name] * 0.0929 - - -def speed(df, name): - df[name] = df[name] * 196.85039370078738 - - -def visibility(df, name): - df[name] = df[name] * 0.6215 - +def convert_data(df, mapping_json): + mapping_dict = json.loads(mapping_json) + for key in mapping_dict: + convert_SI_to_IP(df, key) + return mapping_json -def enthalpy(df, name): - df[name] = df[name] * 0.0004 +def expand_to_hours(value: any, hours: int = 8760) -> list[any]: + """Return a list with the input value repeated for a given number of hours. -def convert_data(df, mapping_json): - df["adaptive_comfort"] = df["adaptive_comfort"] * 1.8 + 32 - df["adaptive_cmf_80_low"] = df["adaptive_cmf_80_low"] * 1.8 + 32 - df["adaptive_cmf_80_up"] = df["adaptive_cmf_80_up"] * 1.8 + 32 - df["adaptive_cmf_90_low"] = df["adaptive_cmf_90_low"] * 1.8 + 32 - df["adaptive_cmf_90_up"] = df["adaptive_cmf_90_up"] * 1.8 + 32 + Args: + value: The value to repeat. + hours: Number of repetitions. Defaults to 8760 (hours in a year). - mapping_dict = json.loads(mapping_json) - for key in json.loads(mapping_json): - if "conversion_function" in mapping_dict[key]: - conversion_function_name = mapping_dict[key]["conversion_function"] - if conversion_function_name is not None: - conversion_function = globals()[conversion_function_name] - conversion_function(df, key) - return json.dumps(mapping_dict) + Returns: + A list containing the value repeated `hours` times. + """ + return [value] * hours if __name__ == "__main__": diff --git a/pages/lib/global_element_ids.py b/pages/lib/global_element_ids.py new file mode 100644 index 00000000..c7969b4a --- /dev/null +++ b/pages/lib/global_element_ids.py @@ -0,0 +1,202 @@ +from enum import Enum + + +class ElementIds(str, Enum): + # ==================== Defines the unique ID constant for each element in the front-end page ==================== + # Shared constants for stores with identical values + SHARED_DF_STORE = "df-store" + SHARED_META_STORE = "meta-store" + SHARED_SI_IP_UNIT_STORE = "si-ip-unit-store" + SHARED_GLOBAL_LOCAL_RADIO_INPUT = "global-local-radio-input" + SHARED_SI_IP_RADIO_INPUT = "si-ip-radio-input" + SHARED_URL_STORE = "url-store" + SHARED_LINES_STORE = "lines-store" + + OUTDOOR_COMFORT_OUTPUT = "outdoor-comfort-output" + DAILY = "daily" + ENABLE_CONDENSATION = "enable-condensation" + ID_T_RH_DROPDOWN = "dropdown" + CUSTOM_SUMMARY = "custom-summary" + CUSTOM_HEATMAP = "custom-heatmap" + HEATMAP = "heatmap" + MAIN_NV_SECTION = "main-nv-section" + NORMALIZE = "normalize" + NV_BAR_CHART = "nv-bar-chart" + NV_DBT_FILTER = "nv-dbt-filter" + NV_DPT_FILTER = "nv-dpt-filter" + NV_DPT_MAX_VAL = "nv-dpt-max-val" + NV_TDB_MIN_VAL = "nv-tdb-min-val" + NV_TDB_MAX_VAL = "nv-tdb-max-val" + NV_HEATMAP_CHART = "nv-heatmap-chart" + OUTDOOR_COMFORT_HOUR_SLIDER = "outdoor-comfort-hour-slider" + NV_MONTH_HOUR_FILTER = "nv-month-hour-filter" + NV_MONTH_SLIDER = "nv-month-slider" + IMAGE_SELECTION = "image-selection" + INVERT_HOUR_OUTDOOR_COMFORT = "invert-hour-outdoor-comfort" + INVERT_MONTH_OUTDOOR_COMFORT = "invert-month-outdoor-comfort" + INVERT_MONTH_NV = "invert-month-nv" + INVERT_HOUR_EXPLORE_DESCRIPTIVE = "invert-hour-explore-descriptive" + INVERT_MONTH_EXPLORE_DESCRIPTIVE = "invert-month-explore-descriptive" + INVERT_MONTH_EXPLORE_HEATMAP = "invert-month-explore-heatmap" + INVERT_HOUR_EXPLORE_HEATMAP = "invert-hour-explore-heatmap" + INVERT_MONTH_EXPLORE_MORE_CHARTS = "invert-month-explore-more-charts" + INVERT_HOUR_EXPLORE_MORE_CHARTS = "invert-hour-explore-more-charts" + INVERT_HOUR_NV = "invert-hour-nv" + OUTDOOR_COMFORT_MONTH_SLIDER = "outdoor-comfort-month-slider" + OUTDOOR_COMFORT_SWITCHES_INPUT = "outdoor-comfort-switches-input" + NV_HOUR_SLIDER = "nv-hour-slider" + PSYCH_CHART = "psych-chart" + PSY_CHART_BTN = "psy-chart-btn" + PSY_COLOR_BY_DROPDOWN = "psy-color-by-dropdown" + DATA_FILTER = "data-filter" + PSY_VAR_DROPDOWN = "psy-var-dropdown" + PSY_HOUR_SLIDER = "psy-hour-slider" + INVERT_HOUR_PSY = "invert-hour-psy" + INVERT_MONTH_PSY = "invert-month-psy" + PSY_MAX_VAL = "psy-max-val" + PSY_MIN_VAL = "psy-min-val" + PSY_MONTH_SLIDER = "psy-month-slider" + ID_OUTDOOR_MONTH_HOUR_FILTER = "month-hour-filter" + MONTH_HOUR_FILTER = "month-hour-filter" + SEC1_HOUR_SLIDER = "sec1-hour-slider" + SEC1_MONTH_SLIDER = "sec1-month-slider" + SEC1_TIME_FILTER_INPUT = "sec1-time-filter-input" + SEC1_VAR_DROPDOWN = "sec1-var-dropdown" + SDATA_FILTER = "data-filter" + SEC2_DATA_FILTER_INPUT = "sec2-data-filter-input" + SEC2_DATA_FILTER_VAR = "sec2-data-filter-var" + SEC2_VAR_DROPDOWN = "sec2-var-dropdown" + SEC2_HOUR_SLIDER = "sec2-hour-slider" + SEC2_TIME_FILTER_INPUT = "sec2-time-filter-input" + SEC2_MONTH_SLIDER = "sec2-month-slider" + SEC2_MIN_VAL = "sec2-min-val" + SEC2_MAX_VAL = "sec2-max-val" + OUTDOOR_DROPDOWN = "outdoor-dropdown" + SWITCHES_INPUT = "switches-input" + TABLE_TMP_HUM = "table-tmp-hum" + TABLE_DATA_EXPLORER = "table-data-explorer" + EXPLORER_SEC2_CONTAINER = "explorer-sec2-container" + EXPLORER_SEC3_DATA_FILTER_INPUT = "explorer-sec3-data-filter-input" + EXPLORER_SEC3_FILTER_VAR_DROPDOWN = "explorer-sec3-filter-var-dropdown" + EXPLORER_SEC3_MIN_VAL = "explorer-sec3-min-val" + EXPLORER_SEC3_MAX_VAL = "explorer-sec3-max-val" + EXPLORER_SEC3_TIME_FILTER_INPUT = "explorer-sec3-time-filter-input" + EXPLORER_SEC3_QUERY_HOUR_SLIDER = "explorer-sec3-query-hour-slider" + EXPLORER_SEC3_QUERY_MONTH_SLIDER = "explorer-sec3-query-month-slider" + EXPLORER_SEC3_VAR_X_DROPDOWN = "explorer-sec3-var-x-dropdown" + EXPLORER_SEC3_VAR_Y_DROPDOWN = "explorer-sec3-var-y-dropdown" + EXPLORER_SEC3_COLORBY_DROPDOWN = "explorer-sec3-colorby-dropdown" + MONTH_HOUR_FILTER_OUTDOOR_COMFORT = "month-hour-filter-outdoor-comfort" + TWO_VAR = "two-var" + THREE_VAR = "three-var" + QUERY_DAILY = "query-daily" + QUERY_HEATMAP = "query-heatmap" + UTCI_CATEGORY_HEATMAP = "utci-category-heatmap" + UTCI_HEATMAP = "utci-heatmap" + UTCI_SUMMARY_CHART = "utci-summary-chart" + YEARLY_CHART = "yearly-chart" + YEARLY_EXPLORE = "yearly-explore" + CUSTOM_SUN_VIEW_DROPDOWN = "custom-sun-view-dropdown" + CUSTOM_SUN_VAR_DROPDOWN = "custom-sun-var-dropdown" + CUSTOM_SUNPATH = "custom-sunpath" + SUN_EXPLORE_DROPDOWN = "sun-explore-dropdown" + SUN_DAILY = "sun-daily" + SUN_HEATMAP = "sun-heatmap" + STATIC_SECTION = "static-section" + TAB_FOUR_CONTAINER = "tab-four-container" + MONTHLY_SOLAR = "monthly-solar" + CLOUD_COVER = "cloud-cover" + SLIDER_CONTAINER = "slider-container" + MONTH_SLIDER = "month-slider" + HOUR_SLIDER = "hour-slider" + WINTER_WIND_ROSE = "winter-wind-rose" + WINTER_WIND_ROSE_TEXT = "winter-wind-rose-text" + SPRING_WIND_ROSE = "spring-wind-rose" + SPRING_WIND_ROSE_TEXT = "spring-wind-rose-text" + SUMMER_WIND_ROSE = "summer-wind-rose" + SUMMER_WIND_ROSE_TEXT = "summer-wind-rose-text" + FALL_WIND_ROSE = "fall-wind-rose" + FALL_WIND_ROSE_TEXT = "fall-wind-rose-text" + WIND_DAILY_CONTAINER = "wind-daily-container" + DAILY_WIND_ROSE_OUTER_CONTAINER = "daily-wind-rose-outer-container" + MORNING_WIND_ROSE = "morning-wind-rose" + MORNING_WIND_ROSE_TEXT = "morning-wind-rose-text" + NOON_WIND_ROSE = "noon-wind-rose" + NOON_WIND_ROSE_TEXT = "noon-wind-rose-text" + NIGHT_WIND_ROSE = "night-wind-rose" + NIGHT_WIND_ROSE_TEXT = "night-wind-rose-text" + CUSTOM_WIND_ROSE = "custom-wind-rose" + WIND_ROSE = "wind-rose" + WIND_SPEED = "wind-speed" + WIND_DIRECTION = "wind-direction" + UPLOAD_DATA = "upload-data" + UPLOAD_DATA_BUTTON = "upload-data-button" + TAB_ONE_MAP = "tab-one-map" + SKELETON_GRAPH_CONTAINER = "skeleton-graph-container" + MODAL_HEADER = "modal-header" + MODAL_CLOSE_BUTTON = "modal-close-button" + MODAL_YES_BUTTON = "modal-yes-button" + MODAL = "modal" + ALERT = "alert" + ID_SELECT_BANNER_SUBTITLE = "banner-selected-location" + TAB_TWO_CONTAINER = "tab-two-container" + SUMMARY_SCE1_CONTAINER = "summary-sec1-container" + LOCATION_INFO = "location-info" + WORLD_MAP = "world-map" + DOWN_EPW_BUTTON = "download-epw-button" + DOWNLOAD_BUTTON = "download-button" + DOWNLOAD_DATAFRAME_CSV = "download-dataframe-csv" + DOWNLOAD_EPW = "download-epw" + WARNING_CDD_HIGHER_HDD = "warning-cdd-higher-hdd" + INPUT_HDD_SET_POINT = "input-hdd-set-point" + INPUT_CDD_SET_POINT = "input-cdd-set-point" + SUBMIT_SET_POINTS = "submit-set-points" + GRAPH_CONTAINER = "graph-container" + DEGREE_DAYS_CHART_WRAPPER = "degree-days-chart-wrapper" + DEGREE_DAYS_CHART = "degree-days-chart" + TEMP_PROFILE_GRAPH = "temp-profile-graph" + HUMIDITY_PROFILE_GRAPH = "humidity-profile-graph" + SOLAR_RADIATION_GRAPH = "solar-radiation-graph" + WIND_SPEED_GRAPH = "wind-speed-graph" + GH_RAD_PROFILE_GRAPH = "gh_rad-profile-graph" + WIND_PROFILE_GRAPH = "wind-profile-graph" + TDB_PROFILE_GRAPH = "tdb-profile-graph" + RH_PROFILE_GRAPH = "rh-profile-graph" + ID_LAYOUT_ALERT_AUTO = "alert-auto" + ID_LAYOUT_INTERVAL_COMPONENT = "interval-component" + FOOTER_CONTAINER = "footer-container" + STORE = "store" + BURGER_BUTTON = "burger-button" + NAVBAR = "navbar" + NAV_GROUP_MAIN = "nav-group-main" + NAV_GROUP_CONTROLS = "nav-group-controls" + NAV_DOC_LINK = "nav-doc-link" + SELECT_URL = "url" + MAIN_URL = "url" + NAV = "nav-" + NAV_SUMMARY = "nav-summary" + NAV_T_RH = "nav-t-rh" + NAV_SUN = "nav-sun" + NAV_WIND = "nav-wind" + NAV_PSY_CHART = "nav-psy-chart" + NAV_EXPLORER = "nav-explorer" + NAV_OUTDOOR = "nav-outdoor" + NAV_NATURAL_VENTILATION = "nav-natural-ventilation" + NAV_CHANGELOG = "nav-changelog" + APP_SHELL = "appshell" + NAVBAR_CONTAINER = "navbar-container" + TOOLS_MENU_EXPANDED = "tools-menu-expanded" + + # Tools Menu - Unified Filter Controls + TOOLS_APPLY_FILTER = "tools-apply-filter" + TOOLS_APPLY_MONTH_HOUR_FILTER = "tools-apply-month-hour-filter" + TOOLS_FILTER_VAR_DROPDOWN = "tools-filter-var-dropdown" + TOOLS_FILTER_MIN_VAL = "tools-filter-min-val" + TOOLS_FILTER_MAX_VAL = "tools-filter-max-val" + TOOLS_MONTH_SLIDER = "tools-month-slider" + TOOLS_HOUR_SLIDER = "tools-hour-slider" + TOOLS_INVERT_MONTH = "tools-invert-month" + TOOLS_INVERT_HOUR = "tools-invert-hour" + TOOLS_FILTER_SECTION = "tools-filter-section" + TOOLS_MONTH_HOUR_SECTION = "tools-month-hour-section" + TOOLS_GLOBAL_FILTER_STORE = "tools-global-filter-store" diff --git a/pages/lib/global_id_buttons.py b/pages/lib/global_id_buttons.py new file mode 100644 index 00000000..afd4fb88 --- /dev/null +++ b/pages/lib/global_id_buttons.py @@ -0,0 +1,29 @@ +class IdButtons: + TABLE_EXPLORE = "table-explore" + NV_NORMALIZE = "nv_normalize" + OUTDOOR_COMFORT_NORMALIZE = "outdoor-comfort-normalize" + TABLE_TMP_RH = "table-tmp-rh" + + DAILY_ROSE_CHART = "daily-rose-chart" + SEASONAL_WIND_ROSE_DOC = "seasonal-wind-rose-doc" + CLIMATE_PROFILES_CHART = "climate-profiles-chart" + PSYCHROMETRIC_CHART_CHART = "Psychrometric-Chart-chart" + CUSTOM_ROSE_CHART = "custom-rose-chart" + HDD_CDD_CHART = "hdd-cdd-chart" + + MORE_CHARTS_LABEL = "more-charts-label" + DOWNLOAD_BUTTON_LABEL = "download-button-label" + CUSTOM_HEATMAP_CHART_LABEL = "custom-heatmap-chart-label" + + EXPLORE_YEARLY_CHART_LABEL = "explore-yearly-chart-label" + EXPLORE_DAILY_CHART_LABEL = "explore-daily-chart-label" + EXPLORE_HEATMAP_CHART_LABEL = "explore-heatmap-chart-label" + NATURAL_VENTILATION_LABEL = "natural-ventilation-label" + UTCI_CHARTS_LABEL = "utci-charts-label" + SUN_PATH_CHART_LABEL = "sun-path-chart-label" + DAILY_CHART_LABEL = "daily-chart-label" + MONTHLY_CHART_LABEL = "monthly-chart-label" + CLOUD_CHART_LABEL = "cloud-chart-label" + YEARLY_CHART_LABEL = "yearly-chart-label" + HEATMAP_CHART_LABEL = "heatmap-chart-label" + WIND_ROSE_LABEL = "wind-rose-label" diff --git a/pages/lib/global_scheme.py b/pages/lib/global_scheme.py index b71d008c..8b93053c 100644 --- a/pages/lib/global_scheme.py +++ b/pages/lib/global_scheme.py @@ -1,62 +1,56 @@ import plotly.io as pio +import numpy as np +from pages.lib.global_variables import Variables, VariableInfo -from config import UnitSystem +WIND_ROSE_BINS = [-1, 0.5, 1.5, 3.3, 5.5, 7.9, 10.7, 13.8, 17.1, 20.7, np.inf] -# Colors Dictionary -blue_red_yellow = ["#00b3ff", "#000082", "#ff0000", "#ffff00"] -dry_humid = ["#ffe600", "#00c8ff", "#0000ff"] -sun_colors = [ - "#293a59", - "#960c2c", - "#ff0000", - "#ff7b00", - "#fffc00", - "#ffff7b", - "#ffffff", -] -light_colors = ["#4d6daa", "#a0beed", "#f1e969", "#eb7d05", "#d81600"] -bright_colors = ["#730a8c", "#0d0db3", "#0f85be", "#0f85be", "#b11421", "#fdf130"] -wind_speed_color = [ - "#ffffff", - "#b2f2ff", - "#33ddff", - "#00aaff", - "#0055ff", - "#0000ff", - "#aa00ff", - "#ff00ff", - "#cc0000", - "#ffaa00", -] -wind_dir_color = ["#0072dd", "#00c420", "#eded00", "#be00d5", "#0072dd"] -cloud_colors = [ - "#7ec9f3", - "#e6eae9", - "#c2c2c2", -] -utci_categories_color = [ - # Let first 10% (0.1) of the values have color rgb(0, 0, 0) - [0, "#2B2977"], - [0.0555, "#2B2977"], - [0.0555, "#38429B"], - [0.0555 + 0.111 * 1, "#38429B"], - [0.0555 + 0.111 * 1, "#4253A4"], - [0.0555 + 0.111 * 2, "#4253A4"], - [0.0555 + 0.111 * 2, "#4B62AD"], - [0.0555 + 0.111 * 3, "#4B62AD"], - [0.0555 + 0.111 * 3, "#68B8E7"], - [0.0555 + 0.111 * 4, "#68B8E7"], - [0.0555 + 0.111 * 4, "#53B848"], - [0.0555 + 0.111 * 5, "#53B848"], - [0.0555 + 0.111 * 5, "#EE8522"], - [0.0555 + 0.111 * 6, "#EE8522"], - [0.0555 + 0.111 * 6, "#EA2C24"], - [0.0555 + 0.111 * 7, "#EA2C24"], - [0.0555 + 0.111 * 7, "#B12224"], - [0.0555 + 0.111 * 8, "#B12224"], - [0.0555 + 0.111 * 8, "#751613"], - [1.0, "#751613"], -] + +def _stepped_colorscale_from_bins(bins, colors): + """ + Build a stepped colorscale from bin edges and colors. + + Args: + bins: sequence/list of N+1 bin edges (ascending). The last edge may be np.inf. + colors: sequence/list of N colors, one per interval. + + Returns: + List of (position, color) tuples for a Plotly colorscale with positions in [0, 1]. + """ + if len(bins) != len(colors) + 1: + raise ValueError( + f"Expected {len(colors) + 1} bin edges for {len(colors)} colors, " + f"got {len(bins)}" + ) + + if any(b2 < b1 for b1, b2 in zip(bins, bins[1:])): + raise ValueError("bins must be in non-decreasing order") + + finite_edges = [b for b in bins if np.isfinite(b)] + if not finite_edges: + raise ValueError("All bin edges are non-finite; cannot build colorscale.") + + vmin, vmax = finite_edges[0], finite_edges[-1] + span = (vmax - vmin) or 1.0 + + cs = [] + for i, c in enumerate(colors): + left_edge, right_edge = bins[i], bins[i + 1] + + left = 0.0 if not np.isfinite(left_edge) else (left_edge - vmin) / span + right = 1.0 if not np.isfinite(right_edge) else (right_edge - vmin) / span + + left = max(0.0, min(1.0, float(left))) + right = max(0.0, min(1.0, float(right))) + + cs.append((left, c)) + cs.append((right, c)) + + return cs + + +wind_speed_colorscale_rose = _stepped_colorscale_from_bins( + WIND_ROSE_BINS, Variables.WIND_SPEED.color +) # containers container_row_center_full = "container-row row-center" @@ -110,810 +104,105 @@ temperature_unit = "\u00b0C" thermal_stress_label = "Thermal stress" -mapping_dictionary = { - "None": {"name": "None"}, - "DOY": {"name": "Day of the year", "unit": "days", "range": [0, 365]}, - "day": {"name": "day", "range": [1, 31]}, - "month": {"name": "months", "unit": "months", "range": [1, 12]}, - "hour": { - "name": "Hour", - "color": [ - "#000000", - "#355e7e", - "#6b5c7b", - "#c06c84", - "#f8b195", - "#c92a42", - "#c92a42", - "#c92a42", - "#000000", - ], - "unit": "h", - "range": [1, 24], - }, - "DBT": { - "name": "Dry bulb temperature", - "color": ["#00b3ff", "#000082", "#ff0000", "#ffff00"], - UnitSystem.SI: { - "unit": "°C", - "range": [-40, 50], - }, - UnitSystem.IP: { - "unit": "°F", - "range": [-40, 122], - }, - "conversion_function": "temperature", - }, - "DPT": { - "name": "Dew point temperature", - "color": ["#00b3ff", "#000082", "#ff0000", "#ffff00"], - UnitSystem.SI: { - "unit": "°C", - "range": [-50, 35], - }, - UnitSystem.IP: { - "unit": "°F", - "range": [-58, 95], - }, - "conversion_function": "temperature", - }, - "RH": { - "name": "Relative humidity", - "color": ["#ffe600", "#00c8ff", "#0000ff"], - UnitSystem.SI: { - "unit": "%", - "range": [0, 100], - }, - UnitSystem.IP: { - "unit": "%", - "range": [0, 100], - }, - "conversion_function": None, - }, - "p_atm": { - "name": "Atmospheric pressure", - "color": [ - "#ffffff", - "#b2f2ff", - "#33ddff", - "#00aaff", - "#0055ff", - "#0000ff", - "#aa00ff", - "#ff00ff", - "#cc0000", - "#ffaa00", - ], - UnitSystem.SI: { - "unit": "Pa", - "range": [95000, 105000], - }, - UnitSystem.IP: { - "unit": "Psi", - "range": [95000 * 0.000145038, 1050000.000145038], - }, - "conversion_function": "pressure", - }, - "extr_hor_rad": { - "name": "Extraterrestrial horizontal irradiation", - "color": [ - "#293a59", - "#960c2c", - "#ff0000", - "#ff7b00", - "#fffc00", - "#ffff7b", - "#ffffff", - ], - UnitSystem.SI: { - "unit": "Wh/m2", - "range": [0, 1200], - }, - UnitSystem.IP: { - "unit": "Btu/ft2", - "range": [0, 1200 * 0.3169983306], - }, - "conversion_function": "irradiation", - }, - "hor_ir_rad": { - "name": "Horizontal infrared radiation", - "color": [ - "#293a59", - "#960c2c", - "#ff0000", - "#ff7b00", - "#fffc00", - "#ffff7b", - "#ffffff", - ], - UnitSystem.SI: { - "unit": "Wh/m2", - "range": [0, 500], - }, - UnitSystem.IP: { - "unit": "Btu/ft2", - "range": [0, 500 * 0.3169983306], - }, - "conversion_function": "irradiation", - }, - "glob_hor_rad": { - "name": "Global horizontal radiation", - "color": [ - "#293a59", - "#960c2c", - "#ff0000", - "#ff7b00", - "#fffc00", - "#ffff7b", - "#ffffff", - ], - UnitSystem.SI: { - "unit": "Wh/m2", - "range": [0, 1200], - }, - UnitSystem.IP: { - "unit": "Btu/ft2", - "range": [0, 1200 * 0.3169983306], - }, - "conversion_function": "irradiation", - }, - "dir_nor_rad": { - "name": "Direct normal radiation", - "color": [ - "#293a59", - "#960c2c", - "#ff0000", - "#ff7b00", - "#fffc00", - "#ffff7b", - "#ffffff", - ], - UnitSystem.SI: { - "unit": "Wh/m2", - "range": [0, 1200], - }, - UnitSystem.IP: { - "unit": "Btu/ft2", - "range": [0, 1200 * 0.3169983306], - }, - "conversion_function": "irradiation", - }, - "dif_hor_rad": { - "name": "Diffuse horizontal radiation", - "color": [ - "#293a59", - "#960c2c", - "#ff0000", - "#ff7b00", - "#fffc00", - "#ffff7b", - "#ffffff", - ], - UnitSystem.SI: { - "unit": "Wh/m2", - "range": [0, 1200], - }, - UnitSystem.IP: { - "unit": "Btu/ft2", - "range": [0, 1200 * 0.3169983306], - }, - "conversion_function": "irradiation", - }, - "glob_hor_ill": { - "name": "Global horizontal illuminance", - "color": ["#4d6daa", "#a0beed", "#f1e969", "#eb7d05", "#d81600"], - UnitSystem.SI: { - "unit": "lux", - "range": [0, 120000], - }, - UnitSystem.IP: { - "unit": "fc", - "range": [0, 120000 * 0.0929], - }, - "conversion_function": "illuminance", - }, - "dir_nor_ill": { - "name": "Direct normal illuminance", - "color": ["#4d6daa", "#a0beed", "#f1e969", "#eb7d05", "#d81600"], - UnitSystem.SI: { - "unit": "lux", - "range": [0, 120000], - }, - UnitSystem.IP: { - "unit": "fc", - "range": [0, 120000 * 0.0929], - }, - "conversion_function": "illuminance", - }, - "dif_hor_ill": { - "name": "Diffuse horizontal illuminance", - "color": ["#4d6daa", "#a0beed", "#f1e969", "#eb7d05", "#d81600"], - UnitSystem.SI: { - "unit": "lux", - "range": [0, 120000], - }, - UnitSystem.IP: { - "unit": "fc", - "range": [0, 120000 * 0.0929], - }, - "conversion_function": "illuminance", - }, - "Zlumi": { - "name": "Zenith luminance", - "color": ["#730a8c", "#0d0db3", "#0f85be", "#0f85be", "#b11421", "#fdf130"], - UnitSystem.SI: { - "unit": "cd/m2", - "range": [0, 60000], - }, - UnitSystem.IP: { - "unit": "cd/ft2", - "range": [0, 60000 * 0.0929], - }, - "conversion_function": "zenith_illuminance", - }, - "wind_dir": { - "name": "Wind direction", - "color": ["#0072dd", "#00c420", "#eded00", "#be00d5", "#0072dd"], - UnitSystem.SI: { - "unit": "°deg", - "range": [0, 360], - }, - UnitSystem.IP: { - "unit": "°deg", - "range": [0, 360], - }, - "conversion_function": None, - }, - "wind_speed": { - "name": "Wind speed", - "color": [ - "#D3D3D3", - "#b2f2ff", - "#33ddff", - "#00aaff", - "#0055ff", - "#0000ff", - "#aa00ff", - "#ff00ff", - "#cc0000", - "#ffaa00", - ], - UnitSystem.SI: { - "unit": "m/s", - "range": [0, 20], - }, - UnitSystem.IP: { - "unit": "fpm", - "range": [0, 20 * 196.85039370078738], - }, - "conversion_function": "speed", - }, - "tot_sky_cover": { - "name": "Total sky cover", - "color": cloud_colors, - UnitSystem.SI: { - "unit": "tenths", - "range": [0, 10], - }, - UnitSystem.IP: { - "unit": "tenths", - "range": [0, 10], - }, - "conversion_function": None, - }, - "Oskycover": { - "name": "Opaque sky cover", - "color": cloud_colors, - UnitSystem.SI: { - "unit": "tenths", - "range": [0, 10], - }, - UnitSystem.IP: { - "unit": "tenths", - "range": [0, 10], - }, - "conversion_function": None, - }, - "Vis": { - "name": "Visibility", - "color": cloud_colors, - UnitSystem.SI: { - "unit": "km", - "range": [0, 100], - }, - UnitSystem.IP: { - "unit": "miles", - "range": [0, 100 * 0.6215], - }, - "conversion_function": "visibility", - }, - "apparent_zenith": { - "name": "Apparent zenith", - "color": [ - "#293a59", - "#960c2c", - "#ff0000", - "#ff7b00", - "#fffc00", - "#ffff7b", - "#ffffff", - ], - UnitSystem.SI: { - "unit": "°deg", - "range": [0, 180], - }, - UnitSystem.IP: { - "unit": "°deg", - "range": [0, 180], - }, - "conversion_function": None, - }, - "zenith": { - "name": "Zenith", - "color": [ - "#293a59", - "#960c2c", - "#ff0000", - "#ff7b00", - "#fffc00", - "#ffff7b", - "#ffffff", - ], - UnitSystem.SI: { - "unit": "°deg", - "range": [0, 180], - }, - UnitSystem.IP: { - "unit": "°deg", - "range": [0, 180], - }, - "conversion_function": None, - }, - "apparent_elevation": { - "name": "Apparent elevation", - "color": [ - "#293a59", - "#960c2c", - "#ff0000", - "#ff7b00", - "#fffc00", - "#ffff7b", - "#ffffff", - ], - UnitSystem.SI: { - "unit": "°deg", - "range": [-90, 90], - }, - UnitSystem.IP: { - "unit": "°deg", - "range": [-90, 90], - }, - "conversion_function": None, - }, - "elevation": { - "name": "Elevation", - "color": [ - "#293a59", - "#960c2c", - "#ff0000", - "#ff7b00", - "#fffc00", - "#ffff7b", - "#ffffff", - ], - UnitSystem.SI: { - "unit": "°deg", - "range": [-90, 90], - }, - UnitSystem.IP: { - "unit": "°deg", - "range": [-90, 90], - }, - "conversion_function": None, - }, - "azimuth": { - "name": "Azimuth", - "color": [ - "#293a59", - "#960c2c", - "#ff0000", - "#ff7b00", - "#fffc00", - "#ffff7b", - "#ffffff", - ], - UnitSystem.SI: { - "unit": "°deg", - "range": [0, 360], - }, - UnitSystem.IP: { - "unit": "°deg", - "range": [0, 360], - }, - "conversion_function": None, - }, - "equation_of_time": { - "name": "Equation of time", - "color": [ - "#293a59", - "#960c2c", - "#ff0000", - "#ff7b00", - "#fffc00", - "#ffff7b", - "#ffffff", - ], - UnitSystem.SI: { - "unit": "°deg", - "range": [-20, 20], - }, - UnitSystem.IP: { - "unit": "°deg", - "range": [-20, 20], - }, - "conversion_function": None, - }, - "utci_Sun_Wind": { - "name": "UTCI: Sun & Wind", - "color": ["#00b3ff", "#000082", "#ff0000", "#ffff00"], - UnitSystem.SI: { - "unit": "°C", - "range": [-70, 70], - }, - UnitSystem.IP: { - "unit": "°F", - "range": [-94, 158], - }, - "conversion_function": "temperature", - }, - "utci_noSun_Wind": { - "name": "UTCI: no Sun & Wind", - "color": ["#00b3ff", "#000082", "#ff0000", "#ffff00"], - UnitSystem.SI: { - "unit": "°C", - "range": [-70, 70], - }, - UnitSystem.IP: { - "unit": "°F", - "range": [-94, 158], - }, - "conversion_function": "temperature", - }, - "utci_Sun_noWind": { - "name": "UTCI: Sun & no Wind", - "color": ["#00b3ff", "#000082", "#ff0000", "#ffff00"], - UnitSystem.SI: { - "unit": "°C", - "range": [-70, 70], - }, - UnitSystem.IP: { - "unit": "°F", - "range": [-94, 158], - }, - "conversion_function": "temperature", - }, - "utci_noSun_noWind": { - "name": "UTCI: no Sun & no Wind", - "color": ["#00b3ff", "#000082", "#ff0000", "#ffff00"], - UnitSystem.SI: { - "unit": "°C", - "range": [-70, 70], - }, - UnitSystem.IP: { - "unit": "°F", - "range": [-94, 158], - }, - "conversion_function": "temperature", - }, - "utci_Sun_Wind_categories": { - "name": "UTCI: Sun & Wind : categories", - "color": [ - [0, "#2B2977"], - [0.0555, "#2B2977"], - [0.0555, "#38429B"], - [0.1665, "#38429B"], - [0.1665, "#4253A4"], - [0.2775, "#4253A4"], - [0.2775, "#4B62AD"], - [0.3885, "#4B62AD"], - [0.3885, "#68B8E7"], - [0.4995, "#68B8E7"], - [0.4995, "#53B848"], - [0.6105, "#53B848"], - [0.6105, "#EE8522"], - [0.7215, "#EE8522"], - [0.7215, "#EA2C24"], - [0.8325, "#EA2C24"], - [0.8325, "#B12224"], - [0.9435, "#B12224"], - [0.9435, "#751613"], - [1.0, "#751613"], - ], - UnitSystem.SI: { - "unit": thermal_stress_label, - "range": [-5, 4], - }, - UnitSystem.IP: { - "unit": thermal_stress_label, - "range": [-5, 4], - }, - "conversion_function": None, - }, - "utci_noSun_Wind_categories": { - "name": "UTCI: no Sun & Wind : categories", - "color": [ - [0, "#2B2977"], - [0.0555, "#2B2977"], - [0.0555, "#38429B"], - [0.1665, "#38429B"], - [0.1665, "#4253A4"], - [0.2775, "#4253A4"], - [0.2775, "#4B62AD"], - [0.3885, "#4B62AD"], - [0.3885, "#68B8E7"], - [0.4995, "#68B8E7"], - [0.4995, "#53B848"], - [0.6105, "#53B848"], - [0.6105, "#EE8522"], - [0.7215, "#EE8522"], - [0.7215, "#EA2C24"], - [0.8325, "#EA2C24"], - [0.8325, "#B12224"], - [0.9435, "#B12224"], - [0.9435, "#751613"], - [1.0, "#751613"], - ], - UnitSystem.SI: { - "unit": thermal_stress_label, - "range": [-5, 4], - }, - UnitSystem.IP: { - "unit": thermal_stress_label, - "range": [-5, 4], - }, - "conversion_function": None, - }, - "utci_Sun_noWind_categories": { - "name": "UTCI: Sun & no Wind : categories", - "color": [ - [0, "#2B2977"], - [0.0555, "#2B2977"], - [0.0555, "#38429B"], - [0.1665, "#38429B"], - [0.1665, "#4253A4"], - [0.2775, "#4253A4"], - [0.2775, "#4B62AD"], - [0.3885, "#4B62AD"], - [0.3885, "#68B8E7"], - [0.4995, "#68B8E7"], - [0.4995, "#53B848"], - [0.6105, "#53B848"], - [0.6105, "#EE8522"], - [0.7215, "#EE8522"], - [0.7215, "#EA2C24"], - [0.8325, "#EA2C24"], - [0.8325, "#B12224"], - [0.9435, "#B12224"], - [0.9435, "#751613"], - [1.0, "#751613"], - ], - UnitSystem.SI: { - "unit": thermal_stress_label, - "range": [-5, 4], - }, - UnitSystem.IP: { - "unit": thermal_stress_label, - "range": [-5, 4], - }, - "conversion_function": None, - }, - "utci_noSun_noWind_categories": { - "name": "UTCI: no Sun & no Wind : categories", - "color": [ - [0, "#2B2977"], - [0.0555, "#2B2977"], - [0.0555, "#38429B"], - [0.1665, "#38429B"], - [0.1665, "#4253A4"], - [0.2775, "#4253A4"], - [0.2775, "#4B62AD"], - [0.3885, "#4B62AD"], - [0.3885, "#68B8E7"], - [0.4995, "#68B8E7"], - [0.4995, "#53B848"], - [0.6105, "#53B848"], - [0.6105, "#EE8522"], - [0.7215, "#EE8522"], - [0.7215, "#EA2C24"], - [0.8325, "#EA2C24"], - [0.8325, "#B12224"], - [0.9435, "#B12224"], - [0.9435, "#751613"], - [1.0, "#751613"], - ], - UnitSystem.SI: { - "unit": thermal_stress_label, - "range": [-5, 4], - }, - UnitSystem.IP: { - "unit": thermal_stress_label, - "range": [-5, 4], - }, - "conversion_function": None, - }, - "p_vap": { - "name": "Vapor partial pressure", - "color": ["#ffe600", "#00c8ff", "#0000ff"], - UnitSystem.SI: { - "unit": "Pa", - "range": [0, 5000], - }, - UnitSystem.IP: { - "unit": "Psi", - "range": [0, 5000 * 0.000145038], - }, - "conversion_function": "pressure", - }, - "p_sat": { - "name": "Saturation pressure", - UnitSystem.SI: { - "unit": "Pa", - "range": [0, 5000], - }, - UnitSystem.IP: { - "unit": "Psi", - "range": [0, 5000 * 0.000145038], - }, - "conversion_function": "pressure", - }, - "hr": { - "name": "Absolute humidity", - "color": ["#ffe600", "#00c8ff", "#0000ff"], - UnitSystem.SI: { - "unit": "g water/kg dry air", - "range": [0, 0.03 * 1000], - }, - UnitSystem.IP: { - "unit": "lb water/klb dry air", - "range": [0, 0.03 * 1000], - }, - "conversion_function": None, - }, - "t_wb": { - "name": "Wet bulb temperature", - "color": ["#00b3ff", "#000082", "#ff0000", "#ffff00"], - UnitSystem.SI: { - "unit": "°C", - "range": [-40, 50], - }, - UnitSystem.IP: { - "unit": "°F", - "range": [-40, 122], - }, - "conversion_function": "temperature", - }, - "t_dp": { - "name": "Dew point temperature", - "color": ["#00b3ff", "#000082", "#ff0000", "#ffff00"], - UnitSystem.SI: { - "unit": "°C", - "range": [-40, 50], - }, - UnitSystem.IP: { - "unit": "°F", - "range": [-40, 122], - }, - "conversion_function": "temperature", - }, - "h": { - "name": "Enthalpy", - "color": ["#00b3ff", "#000082", "#ff0000", "#ffff00"], - UnitSystem.SI: { - "unit": "J/kg dry air", - "range": [0, 110000], - }, - UnitSystem.IP: { - "unit": "Btu/lb dry air", - "range": [0, 110000 * 0.000429923], - }, - "conversion_function": "enthalpy", - }, -} - # Dropdown Names variables_sun_cloud_tab_dropdown = [ - "None", - "t_wb", - "DPT", - "DBT", - "RH", - "p_vap", - "hr", - "extr_hor_rad", - "hor_ir_rad", - "glob_hor_rad", - "dir_nor_rad", - "dif_hor_rad", - "glob_hor_ill", - "dir_nor_ill", - "dif_hor_ill", - "Zlumi", - "wind_dir", - "wind_speed", - "tot_sky_cover", - "Oskycover", - "Vis", + Variables.NONE.col_name, + Variables.T_WB.col_name, + Variables.DPT.col_name, + Variables.DBT.col_name, + Variables.RH.col_name, + Variables.P_VAP.col_name, + Variables.HR.col_name, + Variables.EXTR_HOR_RAD.col_name, + Variables.HOR_IR_RAD.col_name, + Variables.GLOB_HOR_RAD.col_name, + Variables.DIR_NOR_RAD.col_name, + Variables.DIF_HOR_RAD.col_name, + Variables.GLOB_HOR_ILL.col_name, + Variables.DIR_NOR_ILL.col_name, + Variables.DIF_HOR_ILL.col_name, + Variables.ZLUMI.col_name, + Variables.WIND_DIR.col_name, + Variables.WIND_SPEED.col_name, + Variables.TOT_SKY_COVER.col_name, + Variables.OPAQUE_SKY_COVER.col_name, + Variables.VIS.col_name, ] variables_dropdown = [ - "t_wb", - "DPT", - "DBT", - "RH", - "p_vap", - "hr", - "extr_hor_rad", - "hor_ir_rad", - "glob_hor_rad", - "dir_nor_rad", - "dif_hor_rad", - "glob_hor_ill", - "dir_nor_ill", - "dif_hor_ill", - "Zlumi", - "wind_dir", - "wind_speed", - "tot_sky_cover", - "Oskycover", - "Vis", + Variables.T_WB.col_name, + Variables.DPT.col_name, + Variables.DBT.col_name, + Variables.RH.col_name, + Variables.P_VAP.col_name, + Variables.HR.col_name, + Variables.EXTR_HOR_RAD.col_name, + Variables.HOR_IR_RAD.col_name, + Variables.GLOB_HOR_RAD.col_name, + Variables.DIR_NOR_RAD.col_name, + Variables.DIF_HOR_RAD.col_name, + Variables.GLOB_HOR_ILL.col_name, + Variables.DIR_NOR_ILL.col_name, + Variables.DIF_HOR_ILL.col_name, + Variables.ZLUMI.col_name, + Variables.WIND_DIR.col_name, + Variables.WIND_SPEED.col_name, + Variables.TOT_SKY_COVER.col_name, + Variables.OPAQUE_SKY_COVER.col_name, + Variables.VIS.col_name, ] variables_more_variables_dropdown = [ - "utci_Sun_Wind", - "utci_noSun_Wind", - "utci_Sun_noWind", - "utci_noSun_noWind", - "utci_Sun_Wind_categories", - "utci_noSun_Wind_categories", - "utci_Sun_noWind_categories", - "utci_noSun_noWind_categories", - "t_dp", - "elevation", - "azimuth", - "p_sat", + Variables.UTCI_SUN_WIND.col_name, + Variables.UTCI_NO_SUN_WIND.col_name, + Variables.UTCI_SUN_NO_WIND.col_name, + Variables.UTCI_NO_SUN_NO_WIND.col_name, + Variables.UTCI_SUN_WIND_CATEGORIES.col_name, + Variables.UTCI_NOSUN_WIND_CATEGORIES.col_name, + Variables.UTCI_SUN_NOWIND_CATEGORIES.col_name, + Variables.UTCI_NOSUN_NOWIND_CATEGORIES.col_name, + Variables.T_DP.col_name, + Variables.ELEVATION.col_name, + Variables.AZIMUTH.col_name, + Variables.P_SAT.col_name, ] variables_sun_cloud_tab_explore_dropdown = [ - "extr_hor_rad", - "hor_ir_rad", - "glob_hor_rad", - "dir_nor_rad", - "dif_hor_rad", - "glob_hor_ill", - "dir_nor_ill", - "dif_hor_ill", - "Zlumi", - "Oskycover", + Variables.EXTR_HOR_RAD.col_name, + Variables.HOR_IR_RAD.col_name, + Variables.GLOB_HOR_RAD.col_name, + Variables.DIR_NOR_RAD.col_name, + Variables.DIF_HOR_RAD.col_name, + Variables.GLOB_HOR_ILL.col_name, + Variables.DIR_NOR_ILL.col_name, + Variables.DIF_HOR_ILL.col_name, + Variables.ZLUMI.col_name, + Variables.OPAQUE_SKY_COVER.col_name, ] variables_outdoor_dropdown = [ - "utci_Sun_Wind", - "utci_Sun_noWind", - "utci_noSun_Wind", - "utci_noSun_noWind", + Variables.UTCI_SUN_WIND.col_name, + Variables.UTCI_SUN_NO_WIND.col_name, + Variables.UTCI_NO_SUN_WIND.col_name, + Variables.UTCI_NO_SUN_NO_WIND.col_name, ] sun_cloud_tab_dropdown_names = { - mapping_dictionary[key]["name"]: key for key in variables_sun_cloud_tab_dropdown + VariableInfo.from_col_name(key).get_name(): key + for key in variables_sun_cloud_tab_dropdown } -dropdown_names = {mapping_dictionary[key]["name"]: key for key in variables_dropdown} +dropdown_names = { + VariableInfo.from_col_name(key).get_name(): key for key in variables_dropdown +} more_variables_dropdown = { - mapping_dictionary[key]["name"]: key for key in variables_more_variables_dropdown + VariableInfo.from_col_name(key).get_name(): key + for key in variables_more_variables_dropdown } sun_cloud_tab_explore_dropdown_names = { - mapping_dictionary[key]["name"]: key + VariableInfo.from_col_name(key).get_name(): key for key in variables_sun_cloud_tab_explore_dropdown } outdoor_dropdown_names = { - mapping_dictionary[key]["name"]: key for key in variables_outdoor_dropdown + VariableInfo.from_col_name(key).get_name(): key + for key in variables_outdoor_dropdown } diff --git a/pages/lib/global_tab_names.py b/pages/lib/global_tab_names.py new file mode 100644 index 00000000..0e7af9ce --- /dev/null +++ b/pages/lib/global_tab_names.py @@ -0,0 +1,47 @@ +class TabNames: + DAILY = "daily" + DAILY_EXPLORE = "daily_explore" + YEARLY_EXPLORE = "yearly_explore" + + PSY = "psy" + MAP = "map" + HEATMAP = "heatmap" + HEATMAP_CATEGORY = "heatmap_category" + HEATMAP_EXPLORE = "heatmap_explore" + + SCATTER = "scatter" + SUMMARY = "summary" + BARCHART = "barchart" + + HDD_CDD = "hdd_cdd" + WIND_SPEED = "WindSpeed" + CLOUD_COVER = "cloud_cover" + EPW_LOCATION_SELECT = "epw_location_select" + RELATIVE_HUMIDITY = "RelativeHumidity" + RELATIVE_HUMIDITY_HEATMAP = "RelativeHumidity_heatmap" + DRY_BULB_TEMPERATURE = "DryBulbTemperature" + DRY_BULB_TEMPERATURE_HEATMAP = "DryBulbTemperature_heatmap" + + GLOBAL_HORIZONTAL_RADIATION = "GlobalHorizontalRadiation" + GLOBAL_AND_DIFFUSE_HORIZONTAL_SOLAR_RADIATION = ( + "global_and_diffuse_horizontal_solar_radiation" + ) + + SPHERICAL_SUNPATH = "spherical_sunpath" + CARTESIAN_SUNPATH = "cartesian_sunpath" + + DRY_BULB_TEMPERATURE_DAILY = "DryBulbTemperature_daily" + RELATIVE_HUMIDITY_DAILY = "RelativeHumidity_daily" + DRY_BULB_TEMPERATURE_YEARLY = "DryBulbTemperature_yearly" + RELATIVE_HUMIDITY_YEARLY = "RelativeHumidity_yearly" + + ANNUAL_WIND_ROSE = "annual_wind_rose" + WIND_DIRECTION = "wind_direction" + CUSTOM_WIND_ROSE = "custom_wind_rose" + SPRING_WIND_ROSE = "spring_wind_rose" + SUMMER_WIND_ROSE = "summer_wind_rose" + FALL_WIND_ROSE = "fall_wind_rose" + WINTER_WIND_ROSE = "winter_wind_rose" + MORNING_WIND_ROSE = "morning_wind_rose" + NOON_WIND_ROSE = "noon_wind_rose" + NIGHT_WIND_ROSE = "night_wind_rose" diff --git a/pages/lib/global_variables.py b/pages/lib/global_variables.py new file mode 100644 index 00000000..c5aa86a5 --- /dev/null +++ b/pages/lib/global_variables.py @@ -0,0 +1,548 @@ +from dataclasses import dataclass +from typing import Optional, List, Any +from enum import Enum +from config import UnitSystem +from plotly.colors import cyclical + + +class ColorPalettes(Enum): + # Palettes + BLUE_RED_YELLOW = ["#00b3ff", "#000082", "#ff0000", "#ffff00"] + DRY_HUMID = ["#ffe600", "#00c8ff", "#0000ff"] + SUN_COLORS = [ + "#293a59", + "#960c2c", + "#ff0000", + "#ff7b00", + "#fffc00", + "#ffff7b", + "#ffffff", + ] + LIGHT_COLORS = ["#4d6daa", "#a0beed", "#f1e969", "#eb7d05", "#d81600"] + SKY_COVER_TRI = ["#08306b", "#7ec9f3", "#e6eae9"] + + # Unit conversions + PA_TO_PSI = 0.000145038 + WHM2_TO_BTU_FT2 = 0.3169983306 + LUX_TO_FC = 0.0929 + + # UTCI category colorscale + UTCI_CATEGORIES_SCALE = [ + [0, "#2B2977"], + [0.0555, "#2B2977"], + [0.0555, "#38429B"], + [0.1665, "#38429B"], + [0.1665, "#4253A4"], + [0.2775, "#4253A4"], + [0.2775, "#4B62AD"], + [0.3885, "#4B62AD"], + [0.3885, "#68B8E7"], + [0.4995, "#68B8E7"], + [0.4995, "#53B848"], + [0.6105, "#53B848"], + [0.6105, "#EE8522"], + [0.7215, "#EE8522"], + [0.7215, "#EA2C24"], + [0.8325, "#EA2C24"], + [0.8325, "#B12224"], + [0.9435, "#B12224"], + [0.9435, "#751613"], + [1.0, "#751613"], + ] + + +@dataclass +class IP: + """Metadata for the Imperial Units (IP)""" + + unit: str + range: List[float] + + +@dataclass +class VariableInfo: + """Column metadata: default unit/range/color represent the common display; + if SI or IP is provided, they will override the default values as needed.""" + + col_name: str + name: Optional[str] = None + unit: Optional[str] = None + range: Optional[List[float]] = None + color: Optional[List[Any]] = None + IP: Optional[IP] = None + + def get_name(self) -> Optional[str]: + """Returns the display name of the variable.""" + return self.name + + def get_unit(self, system: str) -> Optional[str]: + """Returns the unit of the variable based on the specified unit system.""" + if system == UnitSystem.IP and self.IP: + return self.IP.unit + return self.unit + + def get_range(self, system: str) -> Optional[List[float]]: + """Returns the valid value range of the variable based on the specified unit system.""" + if system == UnitSystem.IP and self.IP: + return self.IP.range + return self.range + + def get_color(self) -> Optional[List[Any]]: + """Returns the color settings of the variable, if available.""" + return self.color + + @classmethod + def from_col_name(cls, col_name: str) -> "VariableInfo": + """Returns the VariableInfo object by matching the column name.""" + for attr_name in dir(Variables): + variable = getattr(Variables, attr_name) + if isinstance(variable, cls) and variable.col_name == col_name: + return variable + raise KeyError(f"No VariableInfo found for col_name='{col_name}'") + + +class Variables: + # ==================== Basic Variables ==================== + NONE = VariableInfo(col_name="None", name="None", unit="", range=[]) + + # ==================== Time Related Variables ==================== + DOY = VariableInfo( + col_name="DOY", name="Day of the year", unit="days", range=[0, 365] + ) + DAY = VariableInfo(col_name="day", name="day", unit="", range=[1, 31]) + YEAR = VariableInfo(col_name="year") + PERIOD = VariableInfo(col_name="period") + MINUTE = VariableInfo(col_name="minute") + FAKE_YEAR = VariableInfo(col_name="fake_year") + MONTH_NAMES = VariableInfo(col_name="month_names") + UTC_TIME = VariableInfo(col_name="UTC_time") + MONTH = VariableInfo(col_name="month", name="months", unit="months", range=[1, 12]) + HOUR = VariableInfo( + col_name="hour", + name="Hour", + unit="h", + range=[1, 24], + color=[ + "#000000", + "#355e7e", + "#6b5c7b", + "#c06c84", + "#f8b195", + "#c92a42", + "#c92a42", + "#c92a42", + "#000000", + ], + ) + + # ==================== Location Related Variables ==================== + LAT = VariableInfo(col_name="lat") # Latitude + LON = VariableInfo(col_name="lon") # Longitude + CITY = VariableInfo(col_name="city") + COUNTRY = VariableInfo(col_name="country") + TIME_ZONE = VariableInfo(col_name="time_zone") + SITE_ELEVATION = VariableInfo(col_name="site_elevation") + + # ==================== Basic Meteorological Data ==================== + DBT = VariableInfo( + col_name="DBT", + name="Dry bulb temperature", + unit="°C", + range=[-40, 50], + color=ColorPalettes.BLUE_RED_YELLOW.value, + IP=IP(unit="°F", range=[-40, 122]), + ) + DPT = VariableInfo( + col_name="DPT", + name="Dew point temperature", + unit="°C", + range=[-50, 35], + color=ColorPalettes.BLUE_RED_YELLOW.value, + IP=IP(unit="°F", range=[-58, 95]), + ) + RH = VariableInfo( + col_name="RH", + name="Relative humidity", + unit="%", + range=[0, 100], + color=ColorPalettes.DRY_HUMID.value, + ) + + P_ATM = VariableInfo( + col_name="p_atm", + name="Atmospheric pressure", + unit="Pa", + range=[95000, 105000], + color=[ + "#ffffff", + "#b2f2ff", + "#33ddff", + "#00aaff", + "#0055ff", + "#0000ff", + "#aa00ff", + "#ff00ff", + "#cc0000", + "#ffaa00", + ], + IP=IP( + unit="Psi", + range=[ + 95000 * ColorPalettes.PA_TO_PSI.value, + 105000 * ColorPalettes.PA_TO_PSI.value, + ], + ), + ) + + # ==================== Radiation Related Variables ==================== + EXTR_HOR_RAD = VariableInfo( + col_name="extr_hor_rad", + name="Extraterrestrial horizontal irradiation", + unit="Wh/m2", + range=[0, 1200], + color=ColorPalettes.SUN_COLORS.value, + IP=IP( + unit="Btu/ft2", + range=[0, 1200 * ColorPalettes.WHM2_TO_BTU_FT2.value], + ), + ) + HOR_IR_RAD = VariableInfo( + col_name="hor_ir_rad", + name="Horizontal infrared radiation", + unit="Wh/m2", + range=[0, 500], + color=ColorPalettes.SUN_COLORS.value, + IP=IP( + unit="Btu/ft2", + range=[0, 500 * ColorPalettes.WHM2_TO_BTU_FT2.value], + ), + ) + GLOB_HOR_RAD = VariableInfo( + col_name="glob_hor_rad", + name="Global horizontal radiation", + unit="Wh/m2", + range=[0, 1200], + color=ColorPalettes.SUN_COLORS.value, + IP=IP( + unit="Btu/ft2", + range=[0, 1200 * ColorPalettes.WHM2_TO_BTU_FT2.value], + ), + ) + DIR_NOR_RAD = VariableInfo( + col_name="dir_nor_rad", + name="Direct normal radiation", + unit="Wh/m2", + range=[0, 1200], + color=ColorPalettes.SUN_COLORS.value, + IP=IP( + unit="Btu/ft2", + range=[0, 1200 * ColorPalettes.WHM2_TO_BTU_FT2.value], + ), + ) + DIF_HOR_RAD = VariableInfo( + col_name="dif_hor_rad", + name="Diffuse horizontal radiation", + unit="Wh/m2", + range=[0, 1200], + color=ColorPalettes.SUN_COLORS.value, + IP=IP( + unit="Btu/ft2", + range=[0, 1200 * ColorPalettes.WHM2_TO_BTU_FT2.value], + ), + ) + + # ==================== Lighting Related Variables ==================== + GLOB_HOR_ILL = VariableInfo( + col_name="glob_hor_ill", + name="Global horizontal illuminance", + unit="lux", + range=[0, 120000], + color=ColorPalettes.LIGHT_COLORS.value, + IP=IP(unit="fc", range=[0, 120000 * ColorPalettes.LUX_TO_FC.value]), + ) + DIR_NOR_ILL = VariableInfo( + col_name="dir_nor_ill", + name="Direct normal illuminance", + unit="lux", + range=[0, 120000], + color=ColorPalettes.LIGHT_COLORS.value, + IP=IP(unit="fc", range=[0, 120000 * ColorPalettes.LUX_TO_FC.value]), + ) + DIF_HOR_ILL = VariableInfo( + col_name="dif_hor_ill", + name="Diffuse horizontal illuminance", + unit="lux", + range=[0, 120000], + color=ColorPalettes.LIGHT_COLORS.value, + IP=IP(unit="fc", range=[0, 120000 * ColorPalettes.LUX_TO_FC.value]), + ) + + ZLUMI = VariableInfo( + col_name="Zlumi", + name="Zenith luminance", + unit="cd/m2", + range=[0, 60000], + color=["#730a8c", "#0d0db3", "#0f85be", "#0f85be", "#b11421", "#fdf130"], + IP=IP(unit="cd/ft2", range=[0, 60000 * 0.0929]), + ) + + # ==================== Wind Related Variables ==================== + WIND_DIR = VariableInfo( + col_name="wind_dir", + name="Wind direction", + unit="°deg", + range=[0, 360], + color=list(cyclical.mrybm), + ) + WIND_SPEED = VariableInfo( + col_name="wind_speed", + name="Wind speed", + unit="m/s", + range=[0, 20], + color=[ + "#feffff", + "#dcf0fc", + "#c2defe", + "#b8cafd", + "#beb1f5", + "#d093df", + "#e272bb", + "#eb4f8b", + "#e52d51", + "#d0210e", + ], + IP=IP(unit="fpm", range=[0, 20 * 196.85039370078738]), + ) + + TOT_SKY_COVER = VariableInfo( + col_name="tot_sky_cover", + name="Total sky cover", + unit="tenths", + range=[0, 10], + color=ColorPalettes.SKY_COVER_TRI.value, + ) + OPAQUE_SKY_COVER = VariableInfo( + col_name="Oskycover", + name="Opaque sky cover", + unit="tenths", + range=[0, 10], + color=ColorPalettes.SKY_COVER_TRI.value, + ) + VIS = VariableInfo( + col_name="Vis", + name="Visibility", + unit="km", + range=[0, 100], + color=ColorPalettes.SKY_COVER_TRI.value, + IP=IP(unit="miles", range=[0, 100 * 0.6215]), + ) + + # ==================== Solar Position Related Variables ==================== + APPARENT_ZENITH = VariableInfo( + col_name="apparent_zenith", + name="Apparent zenith", + unit="°deg", + range=[0, 180], + color=ColorPalettes.SUN_COLORS.value, + ) + ZENITH = VariableInfo( + col_name="zenith", + name="Zenith", + unit="°deg", + range=[0, 180], + color=ColorPalettes.SUN_COLORS.value, + ) + APPARENT_ELEVATION = VariableInfo( + col_name="apparent_elevation", + name="Apparent elevation", + unit="°deg", + range=[-90, 90], + color=ColorPalettes.SUN_COLORS.value, + ) + ELEVATION = VariableInfo( + col_name="elevation", + name="Elevation", + unit="°deg", + range=[-90, 90], + color=ColorPalettes.SUN_COLORS.value, + ) + AZIMUTH = VariableInfo( + col_name="azimuth", + name="Azimuth", + unit="°deg", + range=[0, 360], + color=ColorPalettes.SUN_COLORS.value, + ) + EQUATION_OF_TIME = VariableInfo( + col_name="equation_of_time", + name="Equation of time", + unit="°deg", + range=[-20, 20], + color=ColorPalettes.SUN_COLORS.value, + ) + + # ==================== UTCI Comfort Related Variables ==================== + UTCI_SUN_WIND = VariableInfo( + col_name="utci_Sun_Wind", + name="UTCI: Sun & Wind", + unit="°C", + range=[-70, 70], + color=ColorPalettes.BLUE_RED_YELLOW.value, + IP=IP(unit="°F", range=[-94, 158]), + ) + UTCI_NO_SUN_WIND = VariableInfo( + col_name="utci_noSun_Wind", + name="UTCI: no Sun & Wind", + unit="°C", + range=[-70, 70], + color=ColorPalettes.BLUE_RED_YELLOW.value, + IP=IP(unit="°F", range=[-94, 158]), + ) + UTCI_SUN_NO_WIND = VariableInfo( + col_name="utci_Sun_noWind", + name="UTCI: Sun & no Wind", + unit="°C", + range=[-70, 70], + color=ColorPalettes.BLUE_RED_YELLOW.value, + IP=IP(unit="°F", range=[-94, 158]), + ) + UTCI_NO_SUN_NO_WIND = VariableInfo( + col_name="utci_noSun_noWind", + name="UTCI: no Sun & no Wind", + unit="°C", + range=[-70, 70], + color=ColorPalettes.BLUE_RED_YELLOW.value, + IP=IP(unit="°F", range=[-94, 158]), + ) + + UTCI_SUN_WIND_CATEGORIES = VariableInfo( + col_name="utci_Sun_Wind_categories", + name="UTCI: Sun & Wind : categories", + unit="Thermal stress", + range=[-5, 4], + color=ColorPalettes.UTCI_CATEGORIES_SCALE.value, + ) + UTCI_NOSUN_WIND_CATEGORIES = VariableInfo( + col_name="utci_noSun_Wind_categories", + name="UTCI: no Sun & Wind : categories", + unit="Thermal stress", + range=[-5, 4], + color=ColorPalettes.UTCI_CATEGORIES_SCALE.value, + ) + UTCI_SUN_NOWIND_CATEGORIES = VariableInfo( + col_name="utci_Sun_noWind_categories", + name="UTCI: Sun & no Wind : categories", + unit="Thermal stress", + range=[-5, 4], + color=ColorPalettes.UTCI_CATEGORIES_SCALE.value, + ) + UTCI_NOSUN_NOWIND_CATEGORIES = VariableInfo( + col_name="utci_noSun_noWind_categories", + name="UTCI: no Sun & no Wind : categories", + unit="Thermal stress", + range=[-5, 4], + color=ColorPalettes.UTCI_CATEGORIES_SCALE.value, + ) + + # ==================== Additional Meteorological Data ==================== + P_VAP = VariableInfo( + col_name="p_vap", + name="Vapor partial pressure", + unit="Pa", + range=[0, 5000], + color=ColorPalettes.DRY_HUMID.value, + IP=IP(unit="Psi", range=[0, 5000 * 0.000145038]), + ) + P_SAT = VariableInfo( + col_name="p_sat", + name="Saturation pressure", + unit="Pa", + range=[0, 5000], + IP=IP(unit="Psi", range=[0, 5000 * 0.000145038]), + ) + HR = VariableInfo( + col_name="hr", + name="Absolute humidity", + unit="g water/kg dry air", + range=[0, 0.03 * 1000], + color=ColorPalettes.DRY_HUMID.value, + IP=IP(unit="lb water/klb dry air", range=[0, 0.03 * 1000]), + ) + T_WB = VariableInfo( + col_name="t_wb", + name="Wet bulb temperature", + unit="°C", + range=[-40, 50], + color=ColorPalettes.BLUE_RED_YELLOW.value, + IP=IP(unit="°F", range=[-40, 122]), + ) + T_DP = VariableInfo( + col_name="t_dp", + name="Dew point temperature", + unit="°C", + range=[-40, 50], + color=ColorPalettes.BLUE_RED_YELLOW.value, + IP=IP(unit="°F", range=[-40, 122]), + ) + EH = VariableInfo( + col_name="h", + name="Enthalpy", + unit="J/kg dry air", + range=[0, 110000], + color=ColorPalettes.BLUE_RED_YELLOW.value, + IP=IP(unit="Btu/lb dry air", range=[0, 110000 * 0.000429923]), + ) + + # ==================== Additional Humidity Data ==================== + HI_RH = VariableInfo(col_name="hiRH") + LO_RH = VariableInfo(col_name="loRH") + + # ==================== Additional Wind Data ==================== + WIND_SPEED_UTCI = VariableInfo(col_name="wind_speed_utci") + WIND_SPEED_UTCI_0 = VariableInfo(col_name="wind_speed_utci_0") + + # ==================== Additional Weather Data ==================== + CLOUD_HEIGHT = VariableInfo(col_name="Cheight") + PRECIPITATION_OBSERVATION = VariableInfo( + col_name="PWobs" + ) # Precipitation Observation + PRECIPITATION_CODES = VariableInfo(col_name="PWcodes") # Precipitation Codes + PRECIPITATION_WATER = VariableInfo(col_name="Pwater") # Precipitation Water + AEROSOL_OPTICAL_DEPTH = VariableInfo(col_name="AsolOptD") # Aerosol Optical Depth + SNOW_DEPTH = VariableInfo(col_name="SnowD") # Snow Depth + DAILY_SNOW = VariableInfo(col_name="DaySSnow") # Daily Snow + MRT = VariableInfo(col_name="MRT") + DELTA_MRT = VariableInfo(col_name="delta_mrt") + + # ==================== Adaptive Comfort Variables ==================== + ADAPTIVE_COMFORT = VariableInfo(col_name="adaptive_comfort") + ADAPTIVE_CMF_80_LOW = VariableInfo(col_name="adaptive_cmf_80_low") + ADAPTIVE_CMF_80_UP = VariableInfo(col_name="adaptive_cmf_80_up") + ADAPTIVE_CMF_90_LOW = VariableInfo(col_name="adaptive_cmf_90_low") + ADAPTIVE_CMF_90_UP = VariableInfo(col_name="adaptive_cmf_90_up") + ADAPTIVE_CMF_RMT = VariableInfo(col_name="adaptive_cmf_rmt") + NV_ALLOWED = VariableInfo(col_name="nv_allowed") + TMP_CMF = VariableInfo(col_name="tmp_cmf") + TMP_CMF_80_LOW = VariableInfo(col_name="tmp_cmf_80_low") + TMP_CMF_80_UP = VariableInfo(col_name="tmp_cmf_80_up") + TMP_CMF_90_LOW = VariableInfo(col_name="tmp_cmf_90_low") + TMP_CMF_90_UP = VariableInfo(col_name="tmp_cmf_90_up") + CONVERSION_FUNCTION = VariableInfo(col_name="conversion_function") + + # ==================== UI and Display Variables ==================== + COLOR = VariableInfo(col_name="color") + NAME = VariableInfo(col_name="name") + RANGE = VariableInfo(col_name="range") + UNIT = VariableInfo(col_name="unit") + TWENTY_FOUR_HOUR = VariableInfo(col_name="24h") + FIVE_MINUTE = VariableInfo(col_name="5min") + TIMES = VariableInfo(col_name="times") + PATH = VariableInfo(col_name="path") + FILE_NAME = VariableInfo(col_name="filename") + WIND_DIR_BINS = VariableInfo(col_name="WindDir_bins") + WIND_SPD_BINS = VariableInfo(col_name="WindSpd_bins") + TO_IMAGE_BUTTON_OPTIONS = VariableInfo(col_name="toImageButtonOptions") + INVERT = VariableInfo(col_name="invert") + FEATURES = VariableInfo(col_name="features") + GEOMETRY_COORDINATES = VariableInfo(col_name="geometry.coordinates") + PROP_ID = VariableInfo(col_name="prop_id") diff --git a/pages/lib/layout.py b/pages/lib/layout.py index 558e3745..c66a92ad 100644 --- a/pages/lib/layout.py +++ b/pages/lib/layout.py @@ -1,278 +1,643 @@ -import dash_bootstrap_components as dbc import dash -from dash import dcc, html +from dash import dcc, Input, Output, State, callback import dash_mantine_components as dmc from dash_iconify import DashIconify - +from pages.lib.global_variables import Variables from config import DocLinks, UnitSystem +from pages.lib.global_element_ids import ElementIds +from pages.lib.utils import ( + determine_month_and_hour_filter, + get_default_global_filter_store_data, + get_global_filter_state, +) + + +class NavBarIcons: + _ICON_MAP = { + "Climate Summary": "tabler:chart-bar", + "Temperature and Humidity": "tabler:temperature", + "Sun and Clouds": "tabler:sun", + "Wind": "tabler:wind", + "Psychrometric Chart": "tabler:chart-dots", + "Natural Ventilation": "tabler:windmill", + "Outdoor Comfort": "tabler:thermometer", + "Data Explorer": "tabler:database", + "Changelog": "tabler:history", + } + + CLIMATE_SUMMARY = _ICON_MAP["Climate Summary"] + TEMPERATURE_AND_HUMIDITY = _ICON_MAP["Temperature and Humidity"] + SUN_AND_CLOUDS = _ICON_MAP["Sun and Clouds"] + WIND = _ICON_MAP["Wind"] + PSYCHROMETRIC_CHART = _ICON_MAP["Psychrometric Chart"] + NATURAL_VENTILATION = _ICON_MAP["Natural Ventilation"] + OUTDOOR_COMFORT = _ICON_MAP["Outdoor Comfort"] + DATA_EXPLORER = _ICON_MAP["Data Explorer"] + CHANGELOG = _ICON_MAP["Changelog"] + @classmethod + def get_icon(cls, page_name): + """Get icon for a page name.""" + return cls._ICON_MAP.get(page_name, "tabler:circle") -def alert(): - """Alert for survey.""" - return html.Div( - id="alert-container", + +# global filters +def create_tools_filter_components(): + # Apply month and hour filter (reduced nesting, same visual layout) + return dmc.Stack( + id=ElementIds.TOOLS_MONTH_HOUR_SECTION, children=[ - dbc.Toast( + dmc.Divider(label="Filters", size="xs", color="blue"), + # Month controls + dmc.Text("Month Range:", size="xs", c="dimmed"), + dcc.RangeSlider( + id=ElementIds.TOOLS_MONTH_SLIDER, + min=1, + max=12, + step=1, + value=[1, 12], + marks={1: "1", 12: "12"}, + tooltip={ + "always_visible": False, + "placement": "top", + }, + allowCross=False, + ), + dmc.Group( [ - "If you have a moment, help us improve Clima and take a ", - html.A( - "quick user survey", - href="https://forms.gle/k289zP3R92jdu14M7", - className="alert-link", - target="_blank", + dmc.Switch( + id=ElementIds.TOOLS_INVERT_MONTH, + label="Invert", + checked=False, + size="xs", + color="blue", + style={"fontSize": "0.7rem"}, ), - "! ☀️", ], - id="alert-auto", - header="CBE Clima User Survey", - icon="info", - is_open=False, - dismissable=True, - className="survey-alert", - style={"position": "fixed", "top": 25, "right": 10, "width": 400}, + justify="flex-end", + ), + # Hour controls + dmc.Text("Hour Range:", size="xs", c="dimmed"), + dcc.RangeSlider( + id=ElementIds.TOOLS_HOUR_SLIDER, + min=0, + max=24, + step=1, + value=[0, 24], + marks={0: "0", 24: "24"}, + tooltip={ + "always_visible": False, + "placement": "top", + }, + allowCross=False, + ), + dmc.Group( + [ + dmc.Switch( + id=ElementIds.TOOLS_INVERT_HOUR, + label="Invert", + checked=False, + size="xs", + color="blue", + style={"fontSize": "0.7rem"}, + ), + ], + justify="flex-end", + ), + dmc.Button( + "Apply month and hour filter", + id=ElementIds.TOOLS_APPLY_MONTH_HOUR_FILTER, + color="blue", + variant="filled", + size="xs", ), - dcc.Interval(id="interval-component", interval=12 * 1000, n_intervals=0), ], + gap="xs", + p="xs", ) -def footer(): - """Build the footer at the bottom of the page.""" - return dbc.Row( - align="center", - justify="between", - id="footer-container", +def create_navbar(): + nav_link_styles = { + "root": { + "borderRadius": "0.375rem", + "transition": "all 0.2s ease", + "&:hover": {"backgroundColor": "#e3f2fd"}, + "&[data-active='true']": { + "backgroundColor": "#1976d2", + "color": "white", + "fontWeight": 600, + }, + "&[data-active='true']:hover": { + "backgroundColor": "#1565c0", + "color": "white", + }, + } + } + + # Select weather file - top-level menu item + select_weather_file_page = next( + ( + page + for page in dash.page_registry.values() + if page[Variables.NAME.col_name] == "Select weather file" + ), + None, + ) + select_weather_file_link = ( + dmc.NavLink( + label=select_weather_file_page[Variables.NAME.col_name], + href=select_weather_file_page[Variables.PATH.col_name], + id=f"nav-{select_weather_file_page[Variables.PATH.col_name].replace('/', '')}", + active=False, + styles=nav_link_styles, + ) + if select_weather_file_page + else None + ) + + # Secondary Menu - exclude "Select weather file" as it will be a top-level menu + sub_links = [ + dmc.NavLink( + label=page[Variables.NAME.col_name], + leftSection=DashIconify( + icon=NavBarIcons.get_icon(page[Variables.NAME.col_name]), width=20 + ), + href=page[Variables.PATH.col_name], + id=f"nav-{page[Variables.PATH.col_name].replace('/', '')}", + active=False, + styles=nav_link_styles, + ) + for page in dash.page_registry.values() + if page[Variables.NAME.col_name] + not in ["404", "Changelog", "Select weather file"] + ] + + parent_group = dmc.NavLink( + label="Visualize weather file", + children=sub_links, + id=ElementIds.NAV_GROUP_MAIN, + variant="light", + childrenOffset=0, + opened=True, + ) + + segmented_control_styles = { + "control": {"flex": 1, "minWidth": 0}, + } + + controls_stack = dmc.Stack( + gap="xs", + p="xs", + children=[ + dmc.Divider(label="Units and Ranges", size="xs", color="blue"), + dmc.Tooltip( + label=dmc.Text("You can choose value ranges between Global and Local"), + position="right", + withArrow=True, + children=dmc.SegmentedControl( + id=ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, + value="local", + color="blue", + data=[ + {"label": "Global", "value": "global"}, + {"label": "Local", "value": "local"}, + ], + w=210, + size="sm", + styles=segmented_control_styles, + ), + ), + dmc.Tooltip( + label=dmc.Text("You can choose units between SI and IP"), + position="right", + withArrow=True, + children=dmc.SegmentedControl( + id=ElementIds.SHARED_SI_IP_RADIO_INPUT, + value=UnitSystem.SI, + color="blue", + data=[ + {"label": "SI", "value": UnitSystem.SI}, + {"label": "IP", "value": UnitSystem.IP}, + ], + w=210, + size="sm", + styles=segmented_control_styles, + ), + ), + ], + ) + + filter_components = create_tools_filter_components() + + # Tools + controls_group = dmc.NavLink( + label="Filters and units", + children=[filter_components, controls_stack], + id=ElementIds.NAV_GROUP_CONTROLS, + variant="light", + childrenOffset=0, + opened=True, + ) + + # Documentation + doc_link = dmc.NavLink( + label="Documentation", + href=DocLinks.MAIN.value, + target="_blank", + id=ElementIds.NAV_DOC_LINK, + variant="light", + ) + + return dmc.ScrollArea( children=[ - dbc.Col( + select_weather_file_link, + parent_group, + controls_group, + doc_link, + ], + ) + + +def create_header(): + return dmc.Group( + [ + dmc.Burger( + id=ElementIds.BURGER_BUTTON, + size="sm", + opened=True, + color="blue", + ), + dmc.Anchor( + href="/", + children=dmc.Image(src="assets/img/cbe-logo-small.png", h=40, flex=0), + ), + dmc.Stack( + gap="xs", children=[ - dbc.Row( - html.A( - children=[ - html.Img( - src="assets/img/cbe-logo.png", - ) - ], - href="https://cbe.berkeley.edu/", - ) + dmc.Title( + "CBE Clima Tool", + order=2, + lh=1.1, + c="white", + ), + dmc.Text( + "Current Location: N/A", + id=ElementIds.ID_SELECT_BANNER_SUBTITLE, + size="sm", + style={"overflow": "hidden"}, + c="white", ), ], - width=12, - md=4, - style={"padding": "15px"}, + p="xs", ), - dbc.Col( - children=[ - dbc.Row( - [ - dcc.Markdown( - """ - Please cite us: - Betti, G., Tartarini, F., Nguyen, C, Schiavon, S. CBE Clima Tool: - A free and open-source web application for climate analysis tailored to sustainable building design. - Build. Simul. (2023). [https://doi.org/10.1007/s12273-023-1090-5](https://doi.org/10.1007/s12273-023-1090-5). - """ - ), - dmc.Group( - [ - dmc.Anchor( - "Version: 0.9.0", - href="https://center-for-the-built-environment.gitbook.io/clima/version/changelog", - underline=True, - c="white", - target="_blank", - ), - dmc.Anchor( - "Contributors", - href="https://cbe-berkeley.gitbook.io/clima/#contributions", - underline=True, - c="white", - target="_blank", - ), - dmc.Anchor( - "Report issues on GitHub", - href="https://github.com/CenterForTheBuiltEnvironment/clima/issues", - underline=True, - c="white", - target="_blank", - ), - dmc.Anchor( - "Contact us", - href="https://github.com/CenterForTheBuiltEnvironment/clima/discussions", - underline=True, - c="white", - target="_blank", - ), - dmc.Anchor( - "Documentation", - href="https://center-for-the-built-environment.gitbook.io/clima/", - underline=True, - c="white", - target="_blank", - ), - dmc.Anchor( - "License", - href="https://center-for-the-built-environment.gitbook.io/clima/#license", - underline=True, - c="white", - target="_blank", - ), - ], - spacing="sm", - style={"marginTop": "1rem"}, - ), - ], - style={"marginTop": "1rem"}, + dmc.Alert( + [ + "If you have a moment, help us improve Clima and take a ", + dmc.Anchor( + "quick user survey", + href="https://forms.gle/k289zP3R92jdu14M7", + target="_blank", + c="white", + underline="always", ), + "! ☀️", ], - width=12, - md=8, + id=ElementIds.ID_LAYOUT_ALERT_AUTO, + title="CBE Clima User Survey", + icon=dmc.ThemeIcon( + DashIconify(icon="tabler:info-circle", color="white"), + ), + color="blue", + variant="filled", + withCloseButton=True, + w=400, + pos="fixed", + top="1em", + right="1em", + style={"zIndex": 1002, "display": "none"}, ), ], + pl="md", ) -def banner(): - """Build the banner at the top of the page.""" - return html.Div( - id="banner", - children=[ - dmc.Group( - position="apart", - align="center", +def create_footer(): + white_anchor_style = { + "underline": "always", + "c": "white", + "target": "_blank", + } + + footer_links = [ + ( + "Version: 0.10.1", + "https://center-for-the-built-environment.gitbook.io/clima/version/changelog", + ), + ("Contributors", "https://cbe-berkeley.gitbook.io/clima/#contributions"), + ( + "Report issues on GitHub", + "https://github.com/CenterForTheBuiltEnvironment/clima/issues", + ), + ( + "Contact us", + "https://github.com/CenterForTheBuiltEnvironment/clima/discussions", + ), + ("Documentation", "https://center-for-the-built-environment.gitbook.io/clima/"), + ( + "License", + "https://center-for-the-built-environment.gitbook.io/clima/#license", + ), + ] + + return dmc.Group( + [ + dmc.Anchor( + href="https://cbe.berkeley.edu/", + children=dmc.Image( + src="assets/img/cbe-logo.png", + alt="CBE Logo", + h=40, + w="auto", + fit="contain", + ), + ), + dmc.Stack( + gap="xs", children=[ - dmc.Group( - align="center", - children=[ - html.A( - href="/", - children=[ - dmc.Image( - src="assets/img/cbe-logo-small.png", - height=40, - width="auto", - ) - ], - ), - dmc.Stack( - spacing=0, - children=[ - dmc.Title( - "CBE Clima Tool", - order=1, - id="banner-title", - style={"fontSize": "2rem"}, - ), - dmc.Text( - "Current Location:", - id="banner-subtitle", - size="sm", - ), - ], - ), - ], + dcc.Markdown( + """ + Please cite us: Betti, G., Tartarini, F., Nguyen, C, Schiavon, S. CBE Clima Tool: A free and open-source web application for climate analysis tailored to sustainable building design. Build. Simul. (2023). [https://doi.org/10.1007/s12273-023-1090-5](https://doi.org/10.1007/s12273-023-1090-5). + """, + style={ + "fontSize": "1rem", + "lineHeight": 1.3, + "fontWeight": 400, + "color": "white", + "textAlign": "left", + }, ), dmc.Group( - align="center", - children=[ - html.A( - dmc.Button( - "Documentation", - leftIcon=DashIconify(icon="bi:book-half", width=20), - variant="filled", - color="#5c7cfa", - ), - href=DocLinks.MAIN.value, - target="_blank", - style={"textDecoration": "none"}, - ), - dmc.SegmentedControl( - id="global-local-radio-input", - value="local", - radius="md", - data=[ - {"label": "Global Value Ranges", "value": "global"}, - {"label": "Local Value Ranges", "value": "local"}, - ], - ), - dmc.SegmentedControl( - id="si-ip-radio-input", - value=UnitSystem.SI, - radius="md", - data=[ - { - "label": UnitSystem.SI.upper(), - "value": UnitSystem.SI, - }, - { - "label": UnitSystem.IP.upper(), - "value": UnitSystem.IP, - }, - ], - ), + [ + dmc.Anchor(text, href=url, **white_anchor_style) + for text, url in footer_links ], + gap="sm", + wrap="wrap", + justify="flex-start", ), ], - ) + flex=1, + align="flex-start", + ml="xl", + ), ], + id=ElementIds.FOOTER_CONTAINER, + p="sm", + c="white", + bg="#003262", + justify="flex-start", + align="center", ) -def store(): - return html.Div( - id="store", +def create_stores(): + return dmc.Box( + id=ElementIds.STORE, children=[ - dcc.Store(id="df-store", storage_type="session"), - dcc.Store(id="meta-store", storage_type="session"), - dcc.Store(id="url-store", storage_type="session"), - dcc.Store(id="si-ip-unit-store", storage_type="session"), - dcc.Store(id="lines-store", storage_type="session"), + dcc.Store(id=ElementIds.SHARED_DF_STORE, storage_type="session"), + dcc.Store(id=ElementIds.SHARED_META_STORE, storage_type="session"), + dcc.Store(id=ElementIds.SHARED_URL_STORE, storage_type="session"), + dcc.Store(id=ElementIds.SHARED_SI_IP_UNIT_STORE, storage_type="session"), + dcc.Store(id=ElementIds.SHARED_LINES_STORE, storage_type="session"), + dcc.Store( + id=ElementIds.TOOLS_MENU_EXPANDED, data=False, storage_type="session" + ), + dcc.Store( + id=ElementIds.TOOLS_GLOBAL_FILTER_STORE, + data=get_default_global_filter_store_data(), + storage_type="session", + ), + dcc.Interval( + id=ElementIds.ID_LAYOUT_INTERVAL_COMPONENT, + interval=12 * 1000, + n_intervals=0, + ), ], ) -def build_tabs(): - return html.Div( - id="tabs-container", - children=[ - html.Div( - id="tabs-parent", - className="custom-tabs", - children=[ - dbc.Nav( - [ - dbc.NavItem( - dbc.NavLink( - page["name"], - id=page["path"], - href=page["path"], - active="exact", - className="nav-link", - disabled=True, - ), - className="custom-tab", - ) - for page in dash.page_registry.values() - if page["name"] not in ["404", "changelog"] - ], - id="tabs", - class_name="tab-container", - pills=True, - justified=True, - ) - ], +def create_collapsible_layout(): + return dmc.AppShell( + [ + dmc.AppShellHeader( + create_header(), + bg="#003262", + ), + dmc.AppShellNavbar( + id=ElementIds.NAVBAR, + children=create_navbar(), + bg="#f8f9fa", ), - html.Div( - id="store-container", + # including main and footer + dmc.AppShellMain( children=[ - store(), - html.Div( - id="tabs-content", - children=[ - alert(), # alert can be removed after survey is done - dash.page_container, - ], - ), + create_stores(), + dash.page_container, + create_footer(), ], + pos="relative", + style={ + "zIndex": 1, + "@media (max-width: 48rem)": { + "left": "0", + }, + }, ), ], + header={"height": 80}, + navbar={ + "width": 230, + "breakpoint": "sm", + "collapsed": {"mobile": True, "desktop": False}, + "id": ElementIds.NAVBAR_CONTAINER, + }, + id=ElementIds.APP_SHELL, + ) + + +@callback( + [ + Output(ElementIds.APP_SHELL, "navbar"), + Output(ElementIds.TOOLS_MENU_EXPANDED, "data"), + ], + [ + Input(ElementIds.BURGER_BUTTON, "opened"), + Input(ElementIds.NAV_GROUP_CONTROLS, "opened"), + Input(ElementIds.NAV_GROUP_MAIN, "opened"), + ], + [ + State(ElementIds.APP_SHELL, "navbar"), + State(ElementIds.TOOLS_MENU_EXPANDED, "data"), + ], +) +def toggle_navbar_and_width( + burger_opened, tools_opened, pages_opened, navbar, tools_expanded +): + navbar["collapsed"] = {"mobile": not burger_opened, "desktop": not burger_opened} + + WIDTHS = {"default": 230, "pages": 230, "tools": 230} + + if tools_opened is not None: + tools_expanded = tools_opened + navbar["width"] = ( + WIDTHS["tools"] + if tools_opened + else (WIDTHS["pages"] if pages_opened else WIDTHS["default"]) + ) + elif pages_opened is not None: + navbar["width"] = WIDTHS["pages"] if pages_opened else WIDTHS["default"] + + return navbar, tools_expanded + + +@callback( + [ + Output(f"nav-{page[Variables.PATH.col_name].replace('/', '')}", "active") + for page in dash.page_registry.values() + if page[Variables.NAME.col_name] not in ["404", "Changelog"] + ], + Input(ElementIds.MAIN_URL, "pathname"), + prevent_initial_call=True, +) +def update_nav_active_state(pathname): + return [ + pathname == page[Variables.PATH.col_name] + for page in dash.page_registry.values() + if page[Variables.NAME.col_name] not in ["404", "Changelog"] + ] + + +@callback( + Output(ElementIds.ID_LAYOUT_ALERT_AUTO, "style"), + Input(ElementIds.ID_LAYOUT_INTERVAL_COMPONENT, "n_intervals"), + prevent_initial_call=True, +) +def show_alert_after_delay(n_intervals): + return {"display": "block" if n_intervals == 1 else "none"} + + +@callback( + Output(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), + [ + Input(ElementIds.TOOLS_APPLY_MONTH_HOUR_FILTER, "n_clicks"), + ], + [ + State(ElementIds.TOOLS_MONTH_SLIDER, "value"), + State(ElementIds.TOOLS_HOUR_SLIDER, "value"), + State(ElementIds.TOOLS_INVERT_MONTH, "checked"), + State(ElementIds.TOOLS_INVERT_HOUR, "checked"), + State(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), + ], + prevent_initial_call=True, +) +def update_global_filter_state( + apply_clicks, month_range, hour_range, invert_month, invert_hour, current_data +): + if not apply_clicks: + return current_data or get_default_global_filter_store_data() + + # Normalize existing data, then override with inputs + base_state = get_global_filter_state(current_data) + updated_state = { + **base_state, + "filter_active": True, + "month_range": month_range or base_state["month_range"], + "hour_range": hour_range or base_state["hour_range"], + # store as booleans; readers use get_global_filter_state for coercion + "invert_month": bool(invert_month), + "invert_hour": bool(invert_hour), + } + + return updated_state + + +def apply_global_month_hour_filter(df, filter_store_data, target_columns=None): + filter_state = get_global_filter_state(filter_store_data) + + if not filter_state["filter_active"]: + df_copy = df.copy() + df_copy["_is_filtered"] = False + return df_copy + + month_range = filter_state["month_range"] + hour_range = filter_state["hour_range"] + invert_month = filter_state["invert_month"] + invert_hour = filter_state["invert_hour"] + + start_month, end_month, start_hour, end_hour = determine_month_and_hour_filter( + month_range, hour_range, invert_month, invert_hour + ) + + df_copy = df.copy() + + if target_columns is None: + target_columns = [Variables.DBT.col_name] + elif isinstance(target_columns, str): + target_columns = [target_columns] + + if start_month <= end_month: + month_mask = (df_copy[Variables.MONTH.col_name] < start_month) | ( + df_copy[Variables.MONTH.col_name] > end_month + ) + else: + month_mask = (df_copy[Variables.MONTH.col_name] >= end_month) & ( + df_copy[Variables.MONTH.col_name] <= start_month + ) + + if start_hour <= end_hour: + hour_mask = (df_copy[Variables.HOUR.col_name] < start_hour) | ( + df_copy[Variables.HOUR.col_name] > end_hour + ) + else: + hour_mask = (df_copy[Variables.HOUR.col_name] >= end_hour) & ( + df_copy[Variables.HOUR.col_name] <= start_hour + ) + + df_copy["_is_filtered"] = month_mask | hour_mask + + for target_col in target_columns: + df_copy[f"_{target_col}_original"] = df_copy[target_col] + + from pages.lib.template_graphs import time_filtering + + time_filtering( + df_copy, start_month, end_month, Variables.MONTH.col_name, target_col + ) + time_filtering( + df_copy, start_hour, end_hour, Variables.HOUR.col_name, target_col + ) + + return df_copy + + +@callback( + [ + Output(ElementIds.TOOLS_MONTH_SLIDER, "value"), + Output(ElementIds.TOOLS_HOUR_SLIDER, "value"), + Output(ElementIds.TOOLS_INVERT_MONTH, "checked"), + Output(ElementIds.TOOLS_INVERT_HOUR, "checked"), + ], + [ + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), + ], + prevent_initial_call=False, +) +def sync_sliders_with_global_state(global_filter_data): + state = get_global_filter_state(global_filter_data) + return ( + state["month_range"], + state["hour_range"], + state["invert_month"], + state["invert_hour"], ) diff --git a/pages/lib/template_graphs.py b/pages/lib/template_graphs.py index 514b0ad4..797249b7 100644 --- a/pages/lib/template_graphs.py +++ b/pages/lib/template_graphs.py @@ -1,38 +1,44 @@ -from math import ceil, floor - import numpy as np import pandas as pd import plotly.graph_objects as go from plotly.subplots import make_subplots from config import UnitSystem -from pages.lib.global_scheme import mapping_dictionary +from pages.lib.utils import ( + get_max_min_value, + has_filtered_data, + get_variable_info, + get_variable_range, + get_original_column_values, + calculate_daily_statistics, + unpack_variable_info, +) import dash_bootstrap_components as dbc -from .global_scheme import month_lst, template, tight_margins - -from .utils import code_timer, determine_month_and_hour_filter +from .global_scheme import month_lst, template, tight_margins, WIND_ROSE_BINS +from pages.lib.global_variables import Variables, VariableInfo +from .utils import code_timer, determine_month_and_hour_filter, separate_filtered_data def violin(df, var, global_local, si_ip): """Return day night violin based on the 'var' col""" - mask_day = (df["hour"] >= 8) & (df["hour"] < 20) - mask_night = (df["hour"] < 8) | (df["hour"] >= 20) - var_unit = mapping_dictionary[var][si_ip]["unit"] - var_range = mapping_dictionary[var][si_ip]["range"] - var_name = mapping_dictionary[var]["name"] + mask_day = (df[Variables.HOUR.col_name] >= 8) & (df[Variables.HOUR.col_name] < 20) + mask_night = (df[Variables.HOUR.col_name] < 8) | (df[Variables.HOUR.col_name] >= 20) + var_info = get_variable_info(var, si_ip) + var_unit, var_range, var_name = unpack_variable_info( + var_info, ["var_unit", "var_range", "var_name"] + ) data_day = df.loc[mask_day, var] data_night = df.loc[mask_night, var] if global_local != "global": - data_max = 5 * ceil(df[var].max() / 5) - data_min = 5 * floor(df[var].min() / 5) + data_max, data_min = get_max_min_value(df[var]) var_range = [data_min, data_max] fig = go.Figure() fig.add_trace( go.Violin( - x=df["fake_year"], + x=df[Variables.FAKE_YEAR.col_name], y=data_day, line_color="#ffaa00", name="Day", @@ -44,7 +50,7 @@ def violin(df, var, global_local, si_ip): fig.add_trace( go.Violin( - x=df["fake_year"], + x=df[Variables.FAKE_YEAR.col_name], y=data_night, line_color="#00264d", name="Night", @@ -73,6 +79,7 @@ def violin(df, var, global_local, si_ip): title=title, title_x=0.5, dragmode=False, + height=400, ) fig.update_xaxes(showline=True, linewidth=1, linecolor="black", mirror=True) fig.update_yaxes( @@ -85,79 +92,287 @@ def violin(df, var, global_local, si_ip): @code_timer def yearly_profile(df, var, global_local, si_ip): """Return yearly profile figure based on the 'var' col.""" - var_unit = mapping_dictionary[var][si_ip]["unit"] - var_range = mapping_dictionary[var][si_ip]["range"] - var_name = mapping_dictionary[var]["name"] - var_color = mapping_dictionary[var]["color"] + var_info = get_variable_info(var, si_ip) + var_unit, var_range, var_name, var_color = unpack_variable_info(var_info) + + # Separate filtered and unfiltered data using utility function + filter_info = separate_filtered_data(df, var) + has_filter_marker = filter_info["has_filter_marker"] + filtered_mask = filter_info["filtered_mask"] + original_var_col = filter_info["original_var_col"] + use_original_for_filtered = filter_info["use_original_for_filtered"] + # Calculate y-axis range - use original values if available to keep range consistent if global_local == "global": # Set Global values for Max and minimum range_y = var_range else: # Set maximum and minimum according to data - data_max = 5 * ceil(df[var].max() / 5) - data_min = 5 * floor(df[var].min() / 5) - range_y = [data_min, data_max] + # If filtering is active, use original values to maintain consistent y-axis range + if ( + has_filter_marker + and use_original_for_filtered + and filtered_mask is not None + and filtered_mask.any() + ): + # Combine unfiltered values and original filtered values for range calculation + values_for_range = pd.concat( + [ + df[~filtered_mask][var], + df[filtered_mask][original_var_col], + ] + ).dropna() + # Use combined values if available, otherwise fallback to current values + range_y = get_variable_range( + var, + df, + "local", + si_ip, + use_original_for_range=len(values_for_range) > 0, + original_values=values_for_range if len(values_for_range) > 0 else None, + ) + else: + range_y = get_variable_range(var, df, "local", si_ip) var_single_color = var_color[len(var_color) // 2] custom_ylim = range_y - # Get min, max, and mean of each day - dbt_day = df.groupby(np.arange(len(df.index)) // 24)[var].agg( - ["min", "max", "mean"] - ) - - trace1 = go.Bar( - x=df["UTC_time"].dt.date.unique(), - y=dbt_day["max"] - dbt_day["min"], - base=dbt_day["min"], - marker_color=var_single_color, - marker_opacity=0.3, - name=var_name + " Range", - customdata=np.stack( - (dbt_day["mean"], df.iloc[::24, :]["month_names"], df.iloc[::24, :]["day"]), - axis=-1, - ), - hovertemplate=( - "Max: %{y:.2f} " - + var_unit - + "
Min: %{base:.2f} " - + var_unit - + "
Ave : %{customdata[0]:.2f} " - + var_unit - + "
Month: %{customdata[1]}
Day: %{customdata[2]}
" - ), - ) - trace2 = go.Scatter( - x=df["UTC_time"].dt.date.unique(), - y=dbt_day["mean"], - name="Average " + var_name, - mode="lines", - marker_color=var_single_color, - marker_opacity=1, - customdata=np.stack( - (dbt_day["mean"], df.iloc[::24, :]["month_names"], df.iloc[::24, :]["day"]), - axis=-1, - ), - hovertemplate=( - "Ave : %{customdata[0]:.2f} " - + var_unit - + "
Month: %{customdata[1]}
Day: %{customdata[2]}
" - ), - ) + # Get all unique dates from the full dataframe for consistent x-axis alignment + all_dates = sorted(df[Variables.UTC_TIME.col_name].dt.date.unique()) + + # Get min, max, and mean of each day for unfiltered and filtered data + if has_filter_marker and filtered_mask is not None: + # Use already separated data from filter_info + df_unfiltered = filter_info["df_unfiltered"] + df_filtered = filter_info["df_filtered"] + + # Calculate statistics for unfiltered data + dbt_day_unfiltered = calculate_daily_statistics(df_unfiltered, var) + + # Calculate statistics for filtered data (using original values) + if has_filtered_data(df_filtered) and use_original_for_filtered: + dbt_day_filtered = calculate_daily_statistics(df_filtered, original_var_col) + else: + dbt_day_filtered = None + else: + # No filtering, use full dataframe + df_unfiltered = df + df_filtered = None + dbt_day_unfiltered = calculate_daily_statistics(df, var) + dbt_day_filtered = None + + traces = [] + + # Add filtered data traces (gray) if any filtered data exists + if ( + has_filter_marker + and filtered_mask is not None + and filtered_mask.any() + and dbt_day_filtered is not None + and len(dbt_day_filtered) > 0 + ): + # Reindex to all_dates to ensure consistent x-axis alignment + dbt_day_filtered_reindexed = dbt_day_filtered.reindex(all_dates) + + # Create a mapping from date to month/day names for customdata + df_filtered_date_map = df_filtered.copy() + df_filtered_date_map["_date"] = df_filtered_date_map[ + Variables.UTC_TIME.col_name + ].dt.date + # Get first occurrence of each date for month/day names + date_to_metadata_filtered = df_filtered_date_map.groupby("_date").first() + + # Build customdata arrays aligned with all_dates + filtered_month_names = [ + date_to_metadata_filtered.loc[date, Variables.MONTH_NAMES.col_name] + if date in date_to_metadata_filtered.index + else "" + for date in all_dates + ] + filtered_day_names = [ + date_to_metadata_filtered.loc[date, Variables.DAY.col_name] + if date in date_to_metadata_filtered.index + else "" + for date in all_dates + ] + + trace1_filtered = go.Bar( + x=all_dates, + y=dbt_day_filtered_reindexed["max"] - dbt_day_filtered_reindexed["min"], + base=dbt_day_filtered_reindexed["min"], + marker_color="gray", + marker_opacity=0.3, + name=var_name + " Range (Filtered)", + customdata=np.stack( + ( + dbt_day_filtered_reindexed["mean"].values, + filtered_month_names, + filtered_day_names, + ), + axis=-1, + ), + hovertemplate=( + "Filtered Data
Max: %{y:.2f} " + + var_unit + + "
Min: %{base:.2f} " + + var_unit + + "
Ave : %{customdata[0]:.2f} " + + var_unit + + "
Month: %{customdata[1]}
Day: %{customdata[2]}
" + ), + ) + traces.append(trace1_filtered) + + trace2_filtered = go.Scatter( + x=all_dates, + y=dbt_day_filtered_reindexed["mean"], + name="Average " + var_name + " (Filtered)", + mode="lines", + marker_color="lightgray", + marker_opacity=1, + line=dict(color="lightgray", width=2), + customdata=np.stack( + ( + dbt_day_filtered_reindexed["mean"].values, + filtered_month_names, + filtered_day_names, + ), + axis=-1, + ), + hovertemplate=( + "Filtered Data
Ave : %{customdata[0]:.2f} " + + var_unit + + "
Month: %{customdata[1]}
Day: %{customdata[2]}
" + ), + ) + traces.append(trace2_filtered) + + # Add unfiltered data traces (normal colors) + if len(dbt_day_unfiltered) > 0: + # Reindex to all_dates to ensure consistent x-axis alignment + dbt_day_unfiltered_reindexed = dbt_day_unfiltered.reindex(all_dates) + + # Create a mapping from date to month/day names for customdata + df_unfiltered_date_map = df_unfiltered.copy() + df_unfiltered_date_map["_date"] = df_unfiltered_date_map[ + Variables.UTC_TIME.col_name + ].dt.date + # Get first occurrence of each date for month/day names + date_to_metadata = df_unfiltered_date_map.groupby("_date").first() + + # Build customdata arrays aligned with all_dates + unfiltered_month_names = [ + date_to_metadata.loc[date, Variables.MONTH_NAMES.col_name] + if date in date_to_metadata.index + else "" + for date in all_dates + ] + unfiltered_day_names = [ + date_to_metadata.loc[date, Variables.DAY.col_name] + if date in date_to_metadata.index + else "" + for date in all_dates + ] + + trace1 = go.Bar( + x=all_dates, + y=dbt_day_unfiltered_reindexed["max"] - dbt_day_unfiltered_reindexed["min"], + base=dbt_day_unfiltered_reindexed["min"], + marker_color=var_single_color, + marker_opacity=0.3, + name=var_name + " Range", + customdata=np.stack( + ( + dbt_day_unfiltered_reindexed["mean"].values, + unfiltered_month_names, + unfiltered_day_names, + ), + axis=-1, + ), + hovertemplate=( + "Max: %{y:.2f} " + + var_unit + + "
Min: %{base:.2f} " + + var_unit + + "
Ave : %{customdata[0]:.2f} " + + var_unit + + "
Month: %{customdata[1]}
Day: %{customdata[2]}
" + ), + ) + traces.append(trace1) + + trace2 = go.Scatter( + x=all_dates, + y=dbt_day_unfiltered_reindexed["mean"], + name="Average " + var_name, + mode="lines", + marker_color=var_single_color, + marker_opacity=1, + customdata=np.stack( + ( + dbt_day_unfiltered_reindexed["mean"].values, + unfiltered_month_names, + unfiltered_day_names, + ), + axis=-1, + ), + hovertemplate=( + "Ave : %{customdata[0]:.2f} " + + var_unit + + "
Month: %{customdata[1]}
Day: %{customdata[2]}
" + ), + ) + traces.append(trace2) - if var == "DBT": + if var == Variables.DBT.col_name: # plot ashrae adaptive comfort limits (80%) - lo80 = df.groupby("DOY")["adaptive_cmf_80_low"].mean().values - hi80 = df.groupby("DOY")["adaptive_cmf_80_up"].mean().values - rmt = df.groupby("DOY")["adaptive_cmf_rmt"].mean().values + # Group by DOY and get mean values + doy_grouped = df.groupby(Variables.DOY.col_name) + lo80_by_doy = doy_grouped[Variables.ADAPTIVE_CMF_80_LOW.col_name].mean() + hi80_by_doy = doy_grouped[Variables.ADAPTIVE_CMF_80_UP.col_name].mean() + rmt_by_doy = doy_grouped[Variables.ADAPTIVE_CMF_RMT.col_name].mean() + + # Map DOY values to dates + df_with_date_doy = df.copy() + df_with_date_doy["_date"] = df_with_date_doy[ + Variables.UTC_TIME.col_name + ].dt.date + date_to_doy = df_with_date_doy.groupby("_date")[Variables.DOY.col_name].first() + + # Align ASHRAE values to all_dates + lo80_aligned = [ + lo80_by_doy.get( + date_to_doy.get(date, 1), + lo80_by_doy.iloc[0] if len(lo80_by_doy) > 0 else 0, + ) + for date in all_dates + ] + hi80_aligned = [ + hi80_by_doy.get( + date_to_doy.get(date, 1), + hi80_by_doy.iloc[0] if len(hi80_by_doy) > 0 else 0, + ) + for date in all_dates + ] + rmt_aligned = [ + rmt_by_doy.get( + date_to_doy.get(date, 1), + rmt_by_doy.iloc[0] if len(rmt_by_doy) > 0 else 0, + ) + for date in all_dates + ] + # set color https://github.com/CenterForTheBuiltEnvironment/clima/issues/113 implementation - var_bar_colors = np.where((rmt > 40) | (rmt < 10), "lightgray", "darkgray") + var_bar_colors = np.where( + (np.array(rmt_aligned) > 40) | (np.array(rmt_aligned) < 10), + "lightgray", + "darkgray", + ) trace3 = go.Bar( - x=df["UTC_time"].dt.date.unique(), - y=hi80 - lo80, - base=lo80, + x=all_dates, + y=np.array(hi80_aligned) - np.array(lo80_aligned), + base=lo80_aligned, name="ASHRAE adaptive comfort (80%)", marker_color=var_bar_colors, marker_opacity=0.5, @@ -167,13 +382,29 @@ def yearly_profile(df, var, global_local, si_ip): ) # plot ashrae adaptive comfort limits (90%) - lo90 = df.groupby("DOY")["adaptive_cmf_90_low"].mean().values - hi90 = df.groupby("DOY")["adaptive_cmf_90_up"].mean().values + lo90_by_doy = doy_grouped[Variables.ADAPTIVE_CMF_90_LOW.col_name].mean() + hi90_by_doy = doy_grouped[Variables.ADAPTIVE_CMF_90_UP.col_name].mean() + + # Align ASHRAE values to all_dates + lo90_aligned = [ + lo90_by_doy.get( + date_to_doy.get(date, 1), + lo90_by_doy.iloc[0] if len(lo90_by_doy) > 0 else 0, + ) + for date in all_dates + ] + hi90_aligned = [ + hi90_by_doy.get( + date_to_doy.get(date, 1), + hi90_by_doy.iloc[0] if len(hi90_by_doy) > 0 else 0, + ) + for date in all_dates + ] trace4 = go.Bar( - x=df["UTC_time"].dt.date.unique(), - y=hi90 - lo90, - base=lo90, + x=all_dates, + y=np.array(hi90_aligned) - np.array(lo90_aligned), + base=lo90_aligned, name="ASHRAE adaptive comfort (90%)", marker_color=var_bar_colors, marker_opacity=0.5, @@ -181,31 +412,31 @@ def yearly_profile(df, var, global_local, si_ip): "Max: %{y:.2f} " + var_unit + "Min: %{base:.2f} " + var_unit ), ) - data = [trace3, trace4, trace1, trace2] + # Insert ASHRAE traces before the main traces + traces = [trace3, trace4] + traces - elif var == "RH": + elif var == Variables.RH.col_name: # plot relative Humidity limits (30-70%) - lo_rh = [30] * 365 - hi_rh = [70] * 365 - lo_rh_df = pd.DataFrame({"loRH": lo_rh}) - hi_rh_df = pd.DataFrame({"hiRH": hi_rh}) + # Align to all_dates length + lo_rh = [30] * len(all_dates) + hi_rh = [70] * len(all_dates) trace3 = go.Bar( - x=df["UTC_time"].dt.date.unique(), - y=hi_rh_df["hiRH"] - lo_rh_df["loRH"], - base=lo_rh_df["loRH"], + x=all_dates, + y=np.array(hi_rh) - np.array(lo_rh), + base=lo_rh, name="humidity comfort band", marker_opacity=0.3, marker_color="silver", ) - data = [trace3, trace1, trace2] + # Insert humidity comfort band before the main traces + traces = [trace3] + traces - else: - data = [trace1, trace2] + # traces already contains the main traces (trace1, trace2, and filtered versions if any) fig = go.Figure( - data=data, layout=go.Layout(barmode="overlay", bargap=0, margin=tight_margins) + data=traces, layout=go.Layout(barmode="overlay", bargap=0, margin=tight_margins) ) fig.update_xaxes( @@ -238,21 +469,39 @@ def yearly_profile(df, var, global_local, si_ip): # @code_timer def daily_profile(df, var, global_local, si_ip): """Return the daily profile based on the 'var' col.""" - var_name = mapping_dictionary[var]["name"] - var_unit = mapping_dictionary[var][si_ip]["unit"] - var_range = mapping_dictionary[var][si_ip]["range"] - var_color = mapping_dictionary[var]["color"] - if global_local == "global": - # Set Global values for Max and minimum - range_y = var_range - else: - # Set maximum and minimum according to data - data_max = 5 * ceil(df[var].max() / 5) - data_min = 5 * floor(df[var].min() / 5) - range_y = [data_min, data_max] + var_info = get_variable_info(var, si_ip) + var_name, var_unit, var_color = unpack_variable_info( + var_info, ["var_name", "var_unit", "var_color"] + ) + range_y = get_variable_range(var, df, global_local, si_ip) var_single_color = var_color[len(var_color) // 2] - var_month_ave = df.groupby(["month", "hour"])[var].median().reset_index() + + # Separate filtered and unfiltered data using utility function + filter_info = separate_filtered_data(df, var) + df_unfiltered = filter_info["df_unfiltered"] + df_filtered = filter_info["df_filtered"] + original_var_col = filter_info["original_var_col"] + use_original_for_filtered = filter_info["use_original_for_filtered"] + + # Calculate monthly averages for unfiltered data + var_month_ave = ( + df_unfiltered.groupby([Variables.MONTH.col_name, Variables.HOUR.col_name])[var] + .median() + .reset_index() + ) + + # Calculate monthly averages for filtered data (using original values) + var_month_ave_filtered = None + if has_filtered_data(df_filtered) and use_original_for_filtered: + var_month_ave_filtered = ( + df_filtered.groupby([Variables.MONTH.col_name, Variables.HOUR.col_name])[ + original_var_col + ] + .median() + .reset_index() + ) + fig = make_subplots( rows=1, cols=12, @@ -261,45 +510,122 @@ def daily_profile(df, var, global_local, si_ip): ) for i in range(12): - fig.add_trace( - go.Scatter( - x=df.loc[df["month"] == i + 1, "hour"], - y=df.loc[df["month"] == i + 1, var], - mode="markers", - marker_color=var_single_color, - opacity=0.5, - marker_size=3, - name=month_lst[i], - showlegend=False, - customdata=df.loc[df["month"] == i + 1, "month_names"], - hovertemplate=( - "" - + var - + ": %{y:.2f} " - + var_unit - + "
Month: %{customdata}
Hour: %{x}:00
" + month_data_unfiltered = df_unfiltered.loc[ + df_unfiltered[Variables.MONTH.col_name] == i + 1 + ] + month_data_filtered = None + if has_filtered_data(df_filtered): + month_data_filtered = df_filtered.loc[ + df_filtered[Variables.MONTH.col_name] == i + 1 + ] + + # Add filtered data scatter (gray) if any + if month_data_filtered is not None and len(month_data_filtered) > 0: + filtered_var_values = ( + month_data_filtered[original_var_col] + if use_original_for_filtered + else month_data_filtered[var] + ) + fig.add_trace( + go.Scatter( + x=month_data_filtered[Variables.HOUR.col_name], + y=filtered_var_values, + mode="markers", + marker_color="gray", + opacity=0.3, + marker_size=2, + name=month_lst[i] + " (Filtered)", + showlegend=False, + customdata=month_data_filtered[Variables.MONTH_NAMES.col_name], + hovertemplate=( + "Filtered Data
" + + var + + ": %{y:.2f} " + + var_unit + + "
Month: %{customdata}
Hour: %{x}:00
" + ), ), - ), - row=1, - col=i + 1, - ) + row=1, + col=i + 1, + ) - fig.add_trace( - go.Scatter( - x=var_month_ave.loc[var_month_ave["month"] == i + 1, "hour"], - y=var_month_ave.loc[var_month_ave["month"] == i + 1, var], - mode="lines", - line_color=var_single_color, - line_width=3, - name=None, - showlegend=False, - hovertemplate=( - "" + var + ": %{y:.2f} " + var_unit + "
Hour: %{x}:00
" + # Add filtered data median line (lightgray) if available + if var_month_ave_filtered is not None and len(var_month_ave_filtered) > 0: + month_ave_filtered = var_month_ave_filtered.loc[ + var_month_ave_filtered[Variables.MONTH.col_name] == i + 1 + ] + if len(month_ave_filtered) > 0: + fig.add_trace( + go.Scatter( + x=month_ave_filtered[Variables.HOUR.col_name], + y=month_ave_filtered[original_var_col], + mode="lines", + line_color="lightgray", + line_width=2, + name=None, + showlegend=False, + hovertemplate=( + "Filtered Data
" + + var + + ": %{y:.2f} " + + var_unit + + "
Hour: %{x}:00
" + ), + ), + row=1, + col=i + 1, + ) + + # Add unfiltered data scatter (normal color) + if len(month_data_unfiltered) > 0: + fig.add_trace( + go.Scatter( + x=month_data_unfiltered[Variables.HOUR.col_name], + y=month_data_unfiltered[var], + mode="markers", + marker_color=var_single_color, + opacity=0.5, + marker_size=3, + name=month_lst[i], + showlegend=False, + customdata=month_data_unfiltered[Variables.MONTH_NAMES.col_name], + hovertemplate=( + "" + + var + + ": %{y:.2f} " + + var_unit + + "
Month: %{customdata}
Hour: %{x}:00
" + ), ), - ), - row=1, - col=i + 1, - ) + row=1, + col=i + 1, + ) + + # Add unfiltered data median line (normal color) + month_ave_unfiltered = var_month_ave.loc[ + var_month_ave[Variables.MONTH.col_name] == i + 1 + ] + if len(month_ave_unfiltered) > 0: + fig.add_trace( + go.Scatter( + x=month_ave_unfiltered[Variables.HOUR.col_name], + y=month_ave_unfiltered[var], + mode="lines", + line_color=var_single_color, + line_width=3, + name=None, + showlegend=False, + hovertemplate=( + "" + + var + + ": %{y:.2f} " + + var_unit + + "
Hour: %{x}:00
" + ), + ), + row=1, + col=i + 1, + ) fig.update_xaxes(range=[0, 25], row=1, col=i + 1) fig.update_yaxes(range=range_y, row=1, col=i + 1) @@ -329,21 +655,31 @@ def heatmap_with_filter( invert_month, invert_hour, title, + z_range=None, ): """General function that returns a heatmap.""" - var_unit = mapping_dictionary[var][si_ip]["unit"] - var_range = mapping_dictionary[var][si_ip]["range"] - var_color = mapping_dictionary[var]["color"] + var_info = get_variable_info(var, si_ip) + var_unit, var_range, var_color = unpack_variable_info( + var_info, ["var_unit", "var_range", "var_color"] + ) + + has_global_filter_marker = "_is_filtered" in df.columns + global_filter_mask = None + if has_global_filter_marker: + global_filter_mask = df["_is_filtered"].copy() df = filter_df_by_month_and_hour( df, time_filter, month, hour, invert_month, invert_hour, var ) + if has_global_filter_marker and global_filter_mask is not None: + df["_is_filtered"] = global_filter_mask + start_month, end_month, start_hour, end_hour = determine_month_and_hour_filter( month, hour, invert_month, invert_hour ) - if df.dropna(subset=["month"]).shape[0] == 0: + if df.dropna(subset=[Variables.MONTH.col_name]).shape[0] == 0: return ( dbc.Alert( "No data is available in this location under these conditions. Please " @@ -354,35 +690,111 @@ def heatmap_with_filter( ), ) - if global_local == "global": - # Set Global values for Max and minimum + # For category variables (e.g., UTCI categories), always use global range + # to ensure consistent color mapping regardless of data range + if "_categories" in var: range_z = var_range else: - # Set maximum and minimum according to data - data_max = 5 * ceil(df[var].max() / 5) - data_min = 5 * floor(df[var].min() / 5) - range_z = [data_min, data_max] - fig = go.Figure( - data=go.Heatmap( - y=df["hour"] - 0.5, # Offset by 0.5 to center the hour labels - x=df["UTC_time"].dt.date, - z=df[var], - colorscale=var_color, - zmin=range_z[0], - zmax=range_z[1], - customdata=np.stack((df["month_names"], df["day"]), axis=-1), - hovertemplate=( - "" - + var - + ": %{z:.2f} " - + var_unit - + "
Month: %{customdata[0]}
Day: %{customdata[1]}
Hour:" - " %{y}:00
" - ), - name="", - colorbar=dict(title=var_unit), + range_z = get_variable_range(var, df, global_local, si_ip) + fig = go.Figure() + + has_filter_marker = "_is_filtered" in df.columns + + if has_filter_marker and df["_is_filtered"].any(): + filtered_mask = df["_is_filtered"] + if filtered_mask.any(): + filtered_values = get_original_column_values(df, var) + + filtered_values[~filtered_mask] = None + + fig.add_trace( + go.Heatmap( + y=df[Variables.HOUR.col_name] - 0.5, + x=df[Variables.UTC_TIME.col_name].dt.date, + z=filtered_values, + colorscale=[[0, "lightgray"], [1, "gray"]], + zmin=range_z[0], + zmax=range_z[1], + showscale=False, + customdata=np.stack( + ( + df[Variables.MONTH_NAMES.col_name], + df[Variables.DAY.col_name], + ), + axis=-1, + ), + hovertemplate=( + "Filtered Data
" + + "Month: %{customdata[0]}
Day: %{customdata[1]}
Hour:" + " %{y}:00
" + ), + name="filtered", + ) + ) + + base_values = df[var].copy() + base_values[filtered_mask] = None + + fig.add_trace( + go.Heatmap( + y=df[Variables.HOUR.col_name] - 0.5, + x=df[Variables.UTC_TIME.col_name].dt.date, + z=base_values, + colorscale=var_color, + zmin=range_z[0], + zmax=range_z[1], + customdata=np.stack( + (df[Variables.MONTH_NAMES.col_name], df[Variables.DAY.col_name]), + axis=-1, + ), + hovertemplate=( + "" + + var + + ": %{z:.2f} " + + var_unit + + "
Month: %{customdata[0]}
Day: %{customdata[1]}
Hour:" + " %{y}:00
" + ), + name="", + colorbar=( + dict(title="") if "_categories" in var else dict(title=var_unit) + ), + ) + ) + else: + fig.add_trace( + go.Heatmap( + y=df[Variables.HOUR.col_name] + - 0.5, # Offset by 0.5 to center the hour labels + x=df[Variables.UTC_TIME.col_name].dt.date, + z=df[var], + colorscale=var_color, + zmin=range_z[0], + zmax=range_z[1], + customdata=np.stack( + (df[Variables.MONTH_NAMES.col_name], df[Variables.DAY.col_name]), + axis=-1, + ), + hovertemplate=( + "" + + var + + ": %{z:.2f} " + + var_unit + + "
Month: %{customdata[0]}
Day: %{customdata[1]}
Hour:" + " %{y}:00
" + ), + name="", + colorbar=( + dict(title="") if "_categories" in var else dict(title=var_unit) + ), + ) ) - ) + + if var == Variables.WIND_SPEED.col_name: + spd_bins = list(WIND_ROSE_BINS) + if si_ip == UnitSystem.IP: + spd_bins = convert_bins(spd_bins) + fig.update_traces(zmin=0, zmax=spd_bins[-2]) fig.update_xaxes(dtick="M1", tickformat="%b", ticklabelmode="period") @@ -409,39 +821,105 @@ def heatmap_with_filter( def heatmap(df, var, global_local, si_ip): """General function that returns a heatmap.""" - var_unit = mapping_dictionary[var][si_ip]["unit"] - var_range = mapping_dictionary[var][si_ip]["range"] - var_color = mapping_dictionary[var]["color"] + var_info = get_variable_info(var, si_ip) + var_unit, var_range, var_color = unpack_variable_info( + var_info, ["var_unit", "var_range", "var_color"] + ) + range_z = get_variable_range(var, df, global_local, si_ip) + fig = go.Figure() - if global_local == "global": - # Set Global values for Max and minimum - range_z = var_range + has_filter_marker = "_is_filtered" in df.columns + + if has_filter_marker and df["_is_filtered"].any(): + filtered_mask = df["_is_filtered"] + if filtered_mask.any(): + filtered_values = get_original_column_values(df, var) + + filtered_values[~filtered_mask] = None + + fig.add_trace( + go.Heatmap( + y=df[Variables.HOUR.col_name], + x=df[Variables.UTC_TIME.col_name].dt.date, + z=filtered_values, + colorscale=[[0, "lightgray"], [1, "gray"]], + zmin=range_z[0], + zmax=range_z[1], + showscale=False, + customdata=np.stack( + ( + df[Variables.MONTH_NAMES.col_name], + df[Variables.DAY.col_name], + ), + axis=-1, + ), + hovertemplate=( + "Filtered Data
" + + "Month: %{customdata[0]}
Day: %{customdata[1]}
Hour:" + " %{y}:00
" + ), + name="filtered", + ) + ) + + base_values = df[var].copy() + base_values[filtered_mask] = None + + fig.add_trace( + go.Heatmap( + y=df[Variables.HOUR.col_name], + x=df[Variables.UTC_TIME.col_name].dt.date, + z=base_values, + colorscale=var_color, + zmin=range_z[0], + zmax=range_z[1], + customdata=np.stack( + (df[Variables.MONTH_NAMES.col_name], df[Variables.DAY.col_name]), + axis=-1, + ), + hovertemplate=( + "" + + var + + ": %{z:.2f} " + + var_unit + + "
Month: %{customdata[0]}
Day: %{customdata[1]}
Hour:" + " %{y}:00
" + ), + name="", + colorbar=dict(title=var_unit), + ) + ) else: - # Set maximum and minimum according to data - data_max = 5 * ceil(df[var].max() / 5) - data_min = 5 * floor(df[var].min() / 5) - range_z = [data_min, data_max] - fig = go.Figure( - data=go.Heatmap( - y=df["hour"], - x=df["UTC_time"].dt.date, - z=df[var], - colorscale=var_color, - zmin=range_z[0], - zmax=range_z[1], - customdata=np.stack((df["month_names"], df["day"]), axis=-1), - hovertemplate=( - "" - + var - + ": %{z:.2f} " - + var_unit - + "
Month: %{customdata[0]}
Day: %{customdata[1]}
Hour:" - " %{y}:00
" - ), - name="", - colorbar=dict(title=var_unit), + fig.add_trace( + go.Heatmap( + y=df[Variables.HOUR.col_name], + x=df[Variables.UTC_TIME.col_name].dt.date, + z=df[var], + colorscale=var_color, + zmin=range_z[0], + zmax=range_z[1], + customdata=np.stack( + (df[Variables.MONTH_NAMES.col_name], df[Variables.DAY.col_name]), + axis=-1, + ), + hovertemplate=( + "" + + var + + ": %{z:.2f} " + + var_unit + + "
Month: %{customdata[0]}
Day: %{customdata[1]}
Hour:" + " %{y}:00
" + ), + name="", + colorbar=dict(title=var_unit), + ) ) - ) + + if var == Variables.WIND_SPEED.col_name: + spd_bins = list(WIND_ROSE_BINS) + if si_ip == UnitSystem.IP: + spd_bins = convert_bins(spd_bins) + fig.update_traces(zmin=0, zmax=spd_bins[-2]) fig.update_xaxes(dtick="M1", tickformat="%b", ticklabelmode="period") @@ -468,27 +946,44 @@ def speed_labels(bins, units): return labels -def wind_rose(df, title, month, hour, labels, si_ip): +def wind_rose(df, title, month, hour, labels, si_ip, skip_time_filter=False): """Return the wind rose figure. Based on: https://gist.github.com/phobson/41b41bdd157a2bcf6e14 """ - start_month = month[0] - end_month = month[1] - start_hour = hour[0] - end_hour = hour[1] - if start_month <= end_month: - df = df.loc[(df["month"] >= start_month) & (df["month"] <= end_month)] - else: - df = df.loc[(df["month"] <= end_month) | (df["month"] >= start_month)] - if start_hour <= end_hour: - df = df.loc[(df["hour"] > start_hour) & (df["hour"] <= end_hour)] - else: - df = df.loc[(df["hour"] <= end_hour) | (df["hour"] >= start_hour)] - - spd_colors = mapping_dictionary["wind_speed"]["color"] - spd_unit = mapping_dictionary["wind_speed"][si_ip]["unit"] - spd_bins = [-1, 0.5, 1.5, 3.3, 5.5, 7.9, 10.7, 13.8, 17.1, 20.7, np.inf] + if not skip_time_filter: + start_month = month[0] + end_month = month[1] + start_hour = hour[0] + end_hour = hour[1] + if start_month <= end_month: + df = df.loc[ + (df[Variables.MONTH.col_name] >= start_month) + & (df[Variables.MONTH.col_name] <= end_month) + ] + else: + df = df.loc[ + (df[Variables.MONTH.col_name] <= end_month) + | (df[Variables.MONTH.col_name] >= start_month) + ] + if start_hour <= end_hour: + df = df.loc[ + (df[Variables.HOUR.col_name] > start_hour) + & (df[Variables.HOUR.col_name] <= end_hour) + ] + else: + df = df.loc[ + (df[Variables.HOUR.col_name] <= end_hour) + | (df[Variables.HOUR.col_name] >= start_hour) + ] + + wind_speed_variable = VariableInfo.from_col_name(Variables.WIND_SPEED.col_name) + + spd_colors = wind_speed_variable.get_color() + spd_unit = wind_speed_variable.get_unit(si_ip) + spd_bins = list( + WIND_ROSE_BINS + ) # Create a copy to avoid modifying the global constant if si_ip == UnitSystem.IP: spd_bins = convert_bins(spd_bins) @@ -496,25 +991,36 @@ def wind_rose(df, title, month, hour, labels, si_ip): dir_bins = np.arange(-22.5 / 2, 360 + 22.5, 22.5) dir_labels = (dir_bins[:-1] + dir_bins[1:]) / 2 total_count = df.shape[0] - calm_count = df.query("wind_speed == 0").shape[0] + calm_count = df.query(f"{Variables.WIND_SPEED.col_name} == 0").shape[0] # Create a temporary DataFrame with binned data df_binned = df.assign( WindSpd_bins=lambda d: pd.cut( - d["wind_speed"], bins=spd_bins, labels=spd_labels, right=True + d[Variables.WIND_SPEED.col_name], + bins=spd_bins, + labels=spd_labels, + right=True, ), WindDir_bins=lambda d: pd.cut( - d["wind_dir"], bins=dir_bins, labels=dir_labels, right=False + d[Variables.WIND_DIR.col_name], + bins=dir_bins, + labels=dir_labels, + right=False, ), ) # Rename the category in the 'WindDir_bins' column - df_binned["WindDir_bins"] = df_binned["WindDir_bins"].rename({360.0: 0.0}) + df_binned[Variables.WIND_DIR_BINS.col_name] = df_binned[ + Variables.WIND_DIR_BINS.col_name + ].rename({360.0: 0.0}) rose = ( - df_binned.groupby(by=["WindSpd_bins", "WindDir_bins"], observed=False) + df_binned.groupby( + by=[Variables.WIND_SPD_BINS.col_name, Variables.WIND_DIR_BINS.col_name], + observed=False, + ) .size() - .unstack(level="WindSpd_bins") + .unstack(level=Variables.WIND_SPD_BINS.col_name) .fillna(0) .assign(calm=lambda d: calm_count / d.shape[0]) .sort_index(axis=1) @@ -575,12 +1081,18 @@ def wind_rose(df, title, month, hour, labels, si_ip): def convert_bins(sbins): - i = 0 + """Convert wind speed bins from m/s to fpm (feet per minute). + + Returns a new list without modifying the input list. + """ + result = [] for x in sbins: - x = x * 196.85039370078738 - sbins[i] = round(x, 1) - i = i + 1 - return sbins + if np.isfinite(x): + converted = round(x * 196.85039370078738, 1) + result.append(converted) + else: + result.append(x) # Preserve np.inf + return result def thermal_stress_stacked_barchart( @@ -611,14 +1123,26 @@ def thermal_stress_stacked_barchart( "#A3302B", "#6B1F18", ] + + # Check if there's a filter marker before applying time filter + has_filter_marker = "_is_filtered" in df.columns + global_filter_mask = None + if has_filter_marker: + global_filter_mask = df["_is_filtered"].copy() + df = filter_df_by_month_and_hour( df, time_filter, month, hour, invert_month, invert_hour, var ) + + # Restore filter marker after time filtering + if has_filter_marker and global_filter_mask is not None: + df["_is_filtered"] = global_filter_mask + start_month, end_month, start_hour, end_hour = determine_month_and_hour_filter( month, hour, invert_month, invert_hour ) - if df.dropna(subset=["month"]).shape[0] == 0: + if df.dropna(subset=[Variables.MONTH.col_name]).shape[0] == 0: return ( dbc.Alert( "No data is available in this location under these conditions. Please " @@ -628,25 +1152,91 @@ def thermal_stress_stacked_barchart( style={"text-align": "center", "marginTop": "2rem"}, ), ) - isNormalized = True if len(normalize) != 0 else False + + # Separate filtered and unfiltered data using utility function + filter_info = separate_filtered_data(df, var) + df_unfiltered = filter_info["df_unfiltered"] + df_filtered = filter_info["df_filtered"] + has_filtered_data_flag = has_filtered_data(df_filtered) + + isNormalized = True if normalize else False + + # Calculate data for unfiltered if isNormalized: - new_df = ( - df.groupby("month")[var].value_counts(normalize=True).unstack(var).fillna(0) + new_df_unfiltered = ( + df_unfiltered.groupby(Variables.MONTH.col_name)[var] + .value_counts(normalize=True) + .unstack(var) + .fillna(0) ) - new_df = new_df.set_axis(categories, axis=1) - new_df.reset_index(inplace=True) + new_df_unfiltered = new_df_unfiltered.set_axis(categories, axis=1) + new_df_unfiltered.reset_index(inplace=True) else: - new_df = df.groupby("month")[var].value_counts().unstack(var).fillna(0) - new_df = new_df.set_axis(categories, axis=1) - new_df.reset_index(inplace=True) + new_df_unfiltered = ( + df_unfiltered.groupby(Variables.MONTH.col_name)[var] + .value_counts() + .unstack(var) + .fillna(0) + ) + new_df_unfiltered = new_df_unfiltered.set_axis(categories, axis=1) + new_df_unfiltered.reset_index(inplace=True) + + # Calculate data for filtered (if any) + new_df_filtered = None + if has_filtered_data_flag: + # Use original values for filtered data if available + original_var_col = f"_{var}_original" + use_original = original_var_col in df_filtered.columns + + if use_original: + # Create a temporary column with original values for calculation + df_filtered_temp = df_filtered.copy() + df_filtered_temp[var] = df_filtered_temp[original_var_col] + else: + df_filtered_temp = df_filtered + + if isNormalized: + new_df_filtered = ( + df_filtered_temp.groupby(Variables.MONTH.col_name)[var] + .value_counts(normalize=True) + .unstack(var) + .fillna(0) + ) + new_df_filtered = new_df_filtered.set_axis(categories, axis=1) + new_df_filtered.reset_index(inplace=True) + else: + new_df_filtered = ( + df_filtered_temp.groupby(Variables.MONTH.col_name)[var] + .value_counts() + .unstack(var) + .fillna(0) + ) + new_df_filtered = new_df_filtered.set_axis(categories, axis=1) + new_df_filtered.reset_index(inplace=True) go.Figure() data = [] + + # Filtered data traces removed - no gray filtering effect for thermal stress chart + + # Add unfiltered data traces (normal colors) for i in range(len(categories)): x_data = list(range(0, 12)) - y_data = [ - catch(lambda: new_df.iloc[mth][categories[i]]) for mth in range(0, 12) - ] + y_data = [] + for mth in range(0, 12): + month_idx = mth + 1 # month index (1-12) + # Check if this month exists in unfiltered data + month_rows = new_df_unfiltered[ + new_df_unfiltered[Variables.MONTH.col_name] == month_idx + ] + if len(month_rows) > 0: + try: + val = month_rows.iloc[0][categories[i]] + y_data.append(val if not pd.isna(val) else 0) + except (KeyError, IndexError, TypeError): + y_data.append(0) + else: + y_data.append(0) data.append( go.Bar( x=x_data, @@ -657,7 +1247,7 @@ def thermal_stress_stacked_barchart( "
Month: %{x}
Category: " + categories[i] + "
Count: %{y}
" - if len(normalize) == 0 + if not normalize else "
Month: %{x}
Category: " + categories[i] + "
Proportion: %{y:.1f}%
" @@ -690,8 +1280,18 @@ def thermal_stress_stacked_barchart( linecolor="black", mirror=True, ) + # Get available months from unfiltered data (or combined if no filter) + available_months = sorted(new_df_unfiltered[Variables.MONTH.col_name].unique()) + if has_filtered_data_flag and new_df_filtered is not None: + filtered_months = sorted(new_df_filtered[Variables.MONTH.col_name].unique()) + available_months = sorted(set(available_months + filtered_months)) + fig.update_xaxes( - dict(tickmode="array", tickvals=np.arange(0, 12, 1), ticktext=month_lst), + dict( + tickmode="array", + tickvals=np.arange(0, len(available_months), 1), + ticktext=month_lst, + ), title_text="Day", showline=True, linewidth=1, @@ -713,22 +1313,36 @@ def barchart(df, var, time_filter_info, data_filter_info, normalize, si_ip): start_hour = time_filter_info[2][0] end_hour = time_filter_info[2][1] - filter_var = str(data_filter_info[1]) - filter_name = mapping_dictionary[filter_var]["name"] - filter_unit = mapping_dictionary[filter_var][si_ip]["unit"] + filter_variable = VariableInfo.from_col_name(str(data_filter_info[1])) + filter_name = filter_variable.get_name() + filter_unit = filter_variable.get_unit(si_ip) - var_unit = mapping_dictionary[var][si_ip]["unit"] - var_name = mapping_dictionary[var]["name"] - var_color = mapping_dictionary[var]["color"] + var_info = get_variable_info(var, si_ip) + var_unit, var_name, var_color = unpack_variable_info( + var_info, ["var_unit", "var_name", "var_color"] + ) color_below = var_color[0] color_above = var_color[-1] color_in = var_color[len(var_color) // 2] new_df = df.copy() + + # Separate filtered and unfiltered data using utility function + filter_info = separate_filtered_data(new_df, var) + has_filter_marker = filter_info["has_filter_marker"] + filtered_mask = filter_info["filtered_mask"] + df_unfiltered = filter_info["df_unfiltered"] + df_filtered = filter_info["df_filtered"] + original_var_col = filter_info["original_var_col"] + use_original_for_filtered = filter_info["use_original_for_filtered"] + month_in = [] month_below = [] month_above = [] + month_in_filtered = [] + month_below_filtered = [] + month_above_filtered = [] min_val = str(min_val) max_val = str(max_val) @@ -736,36 +1350,113 @@ def barchart(df, var, time_filter_info, data_filter_info, normalize, si_ip): if len(time_filter_info) == 1: filter_var = str(var) - for i in range(1, 13): - query = ( - f"month=={str(i)} and ({filter_var}>={min_val} and {filter_var}<={max_val})" - ) - a = new_df.query(query)["DOY"].count() - month_in.append(a) - query = f"month=={str(i)} and ({filter_var}<{min_val})" - b = new_df.query(query)["DOY"].count() - month_below.append(b) - query = f"month=={str(i)} and {filter_var}>{max_val}" - c = new_df.query(query)["DOY"].count() - month_above.append(c) + # Always process all 12 months + available_months_set = set(new_df[Variables.MONTH.col_name].unique()) + + for month_num in range(1, 13): + if month_num in available_months_set: + # Calculate for unfiltered data + month_unfiltered = df_unfiltered[ + df_unfiltered[Variables.MONTH.col_name] == month_num + ] + if len(month_unfiltered) > 0: + query = f"month=={str(month_num)} and ({filter_var}>={min_val} and {filter_var}<={max_val})" + a = month_unfiltered.query(query)[Variables.DOY.col_name].count() + month_in.append(a) + query = f"month=={str(month_num)} and ({filter_var}<{min_val})" + b = month_unfiltered.query(query)[Variables.DOY.col_name].count() + month_below.append(b) + query = f"month=={str(month_num)} and {filter_var}>{max_val}" + c = month_unfiltered.query(query)[Variables.DOY.col_name].count() + month_above.append(c) + else: + month_in.append(0) + month_below.append(0) + month_above.append(0) + + # Calculate for filtered data (using original values) + if has_filtered_data(df_filtered) and use_original_for_filtered: + month_filtered = df_filtered[ + df_filtered[Variables.MONTH.col_name] == month_num + ] + if len(month_filtered) > 0: + filtered_var_col = original_var_col + query = f"month=={str(month_num)} and ({filtered_var_col}>={min_val} and {filtered_var_col}<={max_val})" + a = month_filtered.query(query)[Variables.DOY.col_name].count() + month_in_filtered.append(a) + query = ( + f"month=={str(month_num)} and ({filtered_var_col}<{min_val})" + ) + b = month_filtered.query(query)[Variables.DOY.col_name].count() + month_below_filtered.append(b) + query = f"month=={str(month_num)} and {filtered_var_col}>{max_val}" + c = month_filtered.query(query)[Variables.DOY.col_name].count() + month_above_filtered.append(c) + else: + month_in_filtered.append(0) + month_below_filtered.append(0) + month_above_filtered.append(0) + else: + month_in_filtered.append(0) + month_below_filtered.append(0) + month_above_filtered.append(0) + else: + # No data for this month, append zeros + month_in.append(0) + month_below.append(0) + month_above.append(0) + month_in_filtered.append(0) + month_below_filtered.append(0) + month_above_filtered.append(0) go.Figure() - trace1 = go.Bar( - x=list(range(0, 13)), y=month_in, name="IN range", marker_color=color_in - ) + + month_names = month_lst + + data = [] + + # Add filtered data traces (gray) if any filtered data exists + if ( + has_filter_marker + and filtered_mask is not None + and filtered_mask.any() + and any(month_in_filtered + month_below_filtered + month_above_filtered) + ): + trace1_filtered = go.Bar( + x=month_names, + y=month_in_filtered, + name="IN range (Filtered)", + marker_color="gray", + ) + trace2_filtered = go.Bar( + x=month_names, + y=month_below_filtered, + name="BELOW range (Filtered)", + marker_color="lightgray", + ) + trace3_filtered = go.Bar( + x=month_names, + y=month_above_filtered, + name="ABOVE range (Filtered)", + marker_color="silver", + ) + data = [trace2_filtered, trace1_filtered, trace3_filtered] + + # Add unfiltered data traces (normal colors) + trace1 = go.Bar(x=month_names, y=month_in, name="IN range", marker_color=color_in) trace2 = go.Bar( - x=list(range(0, 13)), + x=month_names, y=month_below, name="BELOW range", marker_color=color_below, ) trace3 = go.Bar( - x=list(range(0, 13)), + x=month_names, y=month_above, name="ABOVE range", marker_color=color_above, ) - data = [trace2, trace1, trace3] + data = data + [trace2, trace1, trace3] fig = go.Figure(data=data) fig.update_layout(barmode="stack", dragmode=False) @@ -821,27 +1512,55 @@ def barchart(df, var, time_filter_info, data_filter_info, normalize, si_ip): return fig +def time_filtering( + df: pd.DataFrame, start_time: int, end_time: int, time_col: str, target_col: str +) -> pd.DataFrame: + """Mask values in the target column based on the given time range. + + Args: + df: Input dataframe. + start_time: Start of the time range. + end_time: End of the time range. + time_col: Column name representing time (e.g., hour or month). + target_col: Column name to apply the mask on. + + Returns: + A modified DataFrame with masked values outside the given time range. + """ + if start_time <= end_time: + mask = (df[time_col] < start_time) | (df[time_col] > end_time) + else: + mask = (df[time_col] >= end_time) & (df[time_col] <= start_time) + df.loc[mask, target_col] = None + return df + + def filter_df_by_month_and_hour( df, time_filter, month, hour, invert_month, invert_hour, var ): + """Apply month and hour filtering to the DataFrame based on user selections. + + Args: + df: Input DataFrame. + time_filter: Whether to apply the time filter. + month: Selected month range. + hour: Selected hour range. + invert_month: Whether to invert the month range. + invert_hour: Whether to invert the hour range. + var: Target variable column name. + + Returns: + Filtered DataFrame with appropriate masking applied. + """ start_month, end_month, start_hour, end_hour = determine_month_and_hour_filter( month, hour, invert_month, invert_hour ) if time_filter: - if start_month <= end_month: - mask = (df["month"] < start_month) | (df["month"] > end_month) - df.loc[mask, var] = None - else: - mask = (df["month"] >= end_month) & (df["month"] <= start_month) - df.loc[mask, var] = None - - if start_hour <= end_hour: - mask = (df["hour"] <= start_hour) | (df["hour"] > end_hour) - df.loc[mask, var] = None - else: - mask = (df["hour"] > end_hour) & (df["hour"] <= start_hour) - df.loc[mask, var] = None + # Month filter + time_filtering(df, start_month, end_month, Variables.MONTH.col_name, var) + # Hour filter + time_filtering(df, start_hour, end_hour, Variables.HOUR.col_name, var) return df diff --git a/pages/lib/utils.py b/pages/lib/utils.py index 860a0409..1611d352 100644 --- a/pages/lib/utils.py +++ b/pages/lib/utils.py @@ -1,13 +1,15 @@ import copy import functools import time +import math -import dash_bootstrap_components as dbc import pandas as pd from dash import html, dash_table, dcc +import dash_mantine_components as dmc from config import UnitSystem -from pages.lib.global_scheme import fig_config, mapping_dictionary, month_lst +from pages.lib.global_scheme import fig_config, month_lst +from pages.lib.global_variables import Variables, VariableInfo def code_timer(func): @@ -33,10 +35,14 @@ def generate_chart_name(tab_name, meta=None, custom_inputs=None, units=None): if units: custom_str += f"_{units}" if meta: - file_name = f"{meta['city']}_{meta['country']}_{tab_name}{custom_str}" - figure_config["toImageButtonOptions"]["filename"] = file_name + file_name = f"{meta[Variables.CITY.col_name]}_{meta[Variables.COUNTRY.col_name]}_{tab_name}{custom_str}" + figure_config[Variables.TO_IMAGE_BUTTON_OPTIONS.col_name][ + Variables.FILE_NAME.col_name + ] = file_name else: - figure_config["toImageButtonOptions"]["filename"] = f"{tab_name}{custom_str}" + figure_config[Variables.TO_IMAGE_BUTTON_OPTIONS.col_name][ + Variables.FILE_NAME.col_name + ] = f"{tab_name}{custom_str}" return figure_config @@ -53,12 +59,13 @@ def generate_units_degree(si_ip): def generate_custom_inputs(var): - if var in mapping_dictionary: - var_fullname = mapping_dictionary[var]["name"] - custom_inputs = "".join(word.capitalize() for word in var_fullname.split(" ")) - return custom_inputs - else: - return None + try: + variable = VariableInfo.from_col_name(var) + if variable.name: + return "".join(word.capitalize() for word in variable.name.split(" ")) + except KeyError: + pass + return None def generate_custom_inputs_time(start_month, end_month, start_hour, end_hour): @@ -87,18 +94,24 @@ def generate_custom_inputs_explorer( month_names = [""] + month_lst start_month_abbr = month_names[int(start_month)] end_month_abbr = month_names[int(end_month)] - if var in mapping_dictionary: - var_fullname = "".join( - word.capitalize() for word in mapping_dictionary[var]["name"].split(" ") + try: + var_name = VariableInfo.from_col_name(var).get_name() + var_fullname = ( + "".join(word.capitalize() for word in var_name.split(" ")) + if var_name + else var ) - else: + except KeyError: var_fullname = var - if filter_var in mapping_dictionary: - filter_fullname = "".join( - word.capitalize() - for word in mapping_dictionary[filter_var]["name"].split(" ") + + try: + filter_name = VariableInfo.from_col_name(filter_var).get_name() + filter_fullname = ( + "".join(word.capitalize() for word in filter_name.split(" ")) + if filter_name + else filter_var ) - else: + except KeyError: filter_fullname = filter_var custom_inputs = f"{var_fullname}_{start_month_abbr}-{end_month_abbr}_{start_hour:02d}-{end_hour:02d}_{filter_fullname}_{min_val}-{max_val}" return custom_inputs @@ -117,20 +130,19 @@ def generate_custom_inputs_psy( month_names = [""] + month_lst start_month_abbr = month_names[int(start_month)] end_month_abbr = month_names[int(end_month)] - if colorby_var in mapping_dictionary: - colorby_fullname = "".join( - word.capitalize() - for word in mapping_dictionary[colorby_var]["name"].split(" ") - ) - else: - colorby_fullname = colorby_var - if data_filter_var in mapping_dictionary: - data_filter_fullname = "".join( - word.capitalize() - for word in mapping_dictionary[data_filter_var]["name"].split(" ") - ) - else: - data_filter_fullname = data_filter_var + + def format_variable_name(var: str) -> str: + try: + variable = VariableInfo.from_col_name(var) + name = variable.get_name() + return ( + "".join(word.capitalize() for word in name.split(" ")) if name else var + ) + except KeyError: + return var + + colorby_fullname = format_variable_name(colorby_var) + data_filter_fullname = format_variable_name(data_filter_var) if colorby_var == "None": custom_inputs = f"{start_month_abbr}-{end_month_abbr}_{start_hour:02d}-{end_hour:02d}_{data_filter_fullname}_{min_val}-{max_val}" @@ -140,38 +152,32 @@ def generate_custom_inputs_psy( def title_with_tooltip(text, tooltip_text, id_button): - display_tooltip = "none" if tooltip_text: - display_tooltip = "block" - - return html.Div( - className="container-row", - style={"padding": "1rem", "marginTop": "1rem"}, - children=[ - html.H5(text, style={"marginRight": "0.5rem"}), - html.Div( - [ - html.Sup( - html.Img( + return dmc.Group( + children=[ + dmc.Title(text, order=3), + dmc.Tooltip( + label=tooltip_text, + position="right", + withArrow=True, + children=[ + dmc.Image( id=id_button, - src="../assets/icons/help.png", + src="/assets/icons/help.png", alt="help", - style={ - "width": "1rem", - "height": "1rem", - }, - ), - ), - dbc.Tooltip( - tooltip_text, - target=id_button, - placement="right", - ), - ], - style={"display": display_tooltip}, - ), - ], - ) + w=16, + h=16, + ) + ], + ), + ], + ) + else: + return dmc.Group( + children=[ + dmc.Title(text, order=3), + ], + ) def title_with_link( @@ -180,33 +186,25 @@ def title_with_link( id_button=None, doc_link: str = "", ): - return html.Div( - className="container-row", - style={"padding": "1rem", "marginTop": "1rem"}, + return dmc.Group( children=[ - html.H5(text, style={"marginRight": "0.5rem"}), - html.Div( - [ - html.Sup( - html.A( - html.Img( - id=id_button, - src="../assets/icons/book.png", - alt="book", - style={ - "width": "1rem", - "height": "1rem", - }, - ), - href=doc_link, - target="_blank", + dmc.Title(text, order=3), + dmc.Tooltip( + label=tooltip_text, + position="right", + withArrow=True, + children=[ + html.A( + dmc.Image( + id=id_button, + src="/assets/icons/book.png", + alt="book", + w=16, + h=16, ), - ), - dbc.Tooltip( - tooltip_text, - target=id_button, - placement="right", - ), + href=doc_link, + target="_blank", + ) ], ), ], @@ -215,13 +213,48 @@ def title_with_link( def summary_table_tmp_rh_tab(df, value, si_ip): df_summary = ( - df.groupby(["month_names", "month"])[value] + df.groupby([Variables.MONTH_NAMES.col_name, Variables.MONTH.col_name])[value] .describe(percentiles=[0.01, 0.25, 0.5, 0.75, 0.99]) .round(2) ) - df_summary = df_summary.reset_index(level="month_names").sort_index() - df_summary = df_summary.drop(["count"], axis=1) - df_summary = df_summary.rename(columns={"month_names": "month"}) + # Robust reset: when groupby is empty, index level names may be lost (None) + df_summary = df_summary.reset_index() + # Ensure we have a single human-readable month column named 'month' + has_month_num = Variables.MONTH.col_name in df_summary.columns + has_month_name = Variables.MONTH_NAMES.col_name in df_summary.columns + if has_month_num: + df_summary = df_summary.sort_values(by=Variables.MONTH.col_name) + if has_month_name and has_month_num: + # Keep readable names as 'month', drop numeric to avoid duplicate columns + df_summary = df_summary.rename( + columns={Variables.MONTH_NAMES.col_name: Variables.MONTH.col_name} + ) + # After rename there will be two 'month' columns; drop the numeric one by position + # Keep the leftmost 'month' (the renamed names column) + cols = [] + seen = set() + for c in df_summary.columns: + if c == Variables.MONTH.col_name: + if c in seen: + continue + seen.add(c) + cols.append(c) + else: + cols.append(c) + df_summary = df_summary.loc[:, cols] + # Explicitly drop the numeric month column if still present as a duplicate + if df_summary.columns.duplicated().any(): + df_summary = df_summary.loc[:, ~df_summary.columns.duplicated()] + elif has_month_name and not has_month_num: + df_summary = df_summary.rename( + columns={Variables.MONTH_NAMES.col_name: Variables.MONTH.col_name} + ) + # Drop 'count' if present + if "count" in df_summary.columns: + df_summary = df_summary.drop(["count"], axis=1) + # Guarantee unique columns + if df_summary.columns.duplicated().any(): + df_summary = df_summary.loc[:, ~df_summary.columns.duplicated()] df_sum = ( df[value] @@ -229,18 +262,25 @@ def summary_table_tmp_rh_tab(df, value, si_ip): .round(2) .to_frame() ) - df_sum = df_sum.T.assign(count="Year").rename(columns={"count": "month"}) + df_sum = df_sum.T.assign(count="Year").rename( + columns={"count": Variables.MONTH.col_name} + ) - df_summary = pd.concat([df_summary, df_sum]) + df_summary = pd.concat([df_summary, df_sum], ignore_index=True) unit = ( - mapping_dictionary[value][si_ip]["unit"] + VariableInfo.from_col_name(value) + .get_unit(si_ip) .replace("", "") .replace("", "") ) return dash_table.DataTable( columns=[ - {"name": i, "id": i} if i == "month" else {"name": f"{i} ({unit})", "id": i} + ( + {"name": i, "id": i} + if i == Variables.MONTH.col_name + else {"name": f"{i} ({unit})", "id": i} + ) for i in df_summary.columns ], style_table={"overflowX": "auto"}, @@ -259,10 +299,10 @@ def summary_table_tmp_rh_tab(df, value, si_ip): def determine_month_and_hour_filter(month, hour, invert_month, invert_hour): start_month, end_month = month - if invert_month == ["invert"] and (start_month != 1 or end_month != 12): + if invert_month and (start_month != 1 or end_month != 12): end_month, start_month = month start_hour, end_hour = hour - if invert_hour == ["invert"] and (start_hour != 0 or end_hour != 24): + if invert_hour and (start_hour != 0 or end_hour != 24): end_hour, start_hour = hour return start_month, end_month, start_hour, end_hour @@ -279,5 +319,163 @@ def dropdown(options=None, **kwargs): return dcc.Dropdown( options=[{"label": k, "value": v} for k, v in options.items()], clearable=False, + style={"width": "14rem"}, **kwargs, ) + + +def get_max_min_value(series: pd.Series, base: int = 5) -> tuple[int, int]: + """Calculate rounded-up max and rounded-down min values based on a base step. + + Args: + series: Pandas Series of numeric values. + base: The rounding base. Default is 5. + + Returns: + Tuple of (max_value, min_value) adjusted to nearest base step. + """ + # Guard against all-NaN series after filtering + non_na = series.dropna() + if non_na.empty: + # Fallback to a symmetric small range to avoid rendering errors + return base, -base + + data_max = base * math.ceil(non_na.max() / base) + data_min = base * math.floor(non_na.min() / base) + return data_max, data_min + + +def get_default_global_filter_store_data() -> dict: + """Return default data structure for TOOLS_GLOBAL_FILTER_STORE. + + Centralizes the default so it can be reused across pages without duplication. + """ + return { + "month_range": [1, 12], + "hour_range": [0, 24], + "invert_month": [], + "invert_hour": [], + "filter_active": False, + } + + +def get_global_filter_state(filter_store_data: dict | None) -> dict: + """Normalize filter store data into a consistent, easy-to-use structure. + + Ensures defaults are applied and types are coerced to booleans where appropriate. + """ + default_data = get_default_global_filter_store_data() + data = ( + default_data if not filter_store_data else {**default_data, **filter_store_data} + ) + + return { + "filter_active": bool(data.get("filter_active", False)), + "month_range": data.get("month_range", [1, 12]), + "hour_range": data.get("hour_range", [0, 24]), + # invert flags may be stored as []/['invert'] or booleans; coerce to bool + "invert_month": bool(data.get("invert_month", [])), + "invert_hour": bool(data.get("invert_hour", [])), + } + + +def get_time_filter_from_store( + filter_store_data: dict | None, +) -> tuple[bool, list[int], list[int], bool, bool]: + """Return normalized time filter arguments from the global filter store. + + Returns (time_filter, month, hour, invert_month, invert_hour). + """ + state = get_global_filter_state(filter_store_data) + return ( + True, + state["month_range"], + state["hour_range"], + state["invert_month"], + state["invert_hour"], + ) + + +def separate_filtered_data(df, var=None): + # Check if there's a filter marker + has_filter_marker = "_is_filtered" in df.columns + filtered_mask = None + if has_filter_marker: + filtered_mask = df["_is_filtered"] + + # Get original values if available + original_var_col = None + use_original_for_filtered = False + if var is not None: + original_var_col = f"_{var}_original" + use_original_for_filtered = has_filter_marker and original_var_col in df.columns + + # Separate filtered and unfiltered data + if has_filter_marker and filtered_mask is not None: + df_unfiltered = df[~filtered_mask].copy() + df_filtered = df[filtered_mask].copy() if filtered_mask.any() else None + else: + df_unfiltered = df + df_filtered = None + + return { + "has_filter_marker": has_filter_marker, + "filtered_mask": filtered_mask, + "df_unfiltered": df_unfiltered, + "df_filtered": df_filtered, + "original_var_col": original_var_col, + "use_original_for_filtered": use_original_for_filtered, + } + + +def has_filtered_data(df_filtered): + return df_filtered is not None and len(df_filtered) > 0 + + +def get_variable_info(var, si_ip): + variable = VariableInfo.from_col_name(var) + return { + "var_unit": variable.get_unit(si_ip), + "var_range": variable.get_range(si_ip), + "var_name": variable.get_name(), + "var_color": variable.get_color(), + } + + +def unpack_variable_info(var_info, keys=None): + if keys is None: + keys = ["var_unit", "var_range", "var_name", "var_color"] + return tuple(var_info[key] for key in keys) + + +def get_variable_range( + var, df, global_local, si_ip, use_original_for_range=False, original_values=None +): + var_info = get_variable_info(var, si_ip) + var_range = var_info["var_range"] + + if global_local == "global": + return var_range + else: + if use_original_for_range and original_values is not None: + data_max, data_min = get_max_min_value(original_values) + else: + data_max, data_min = get_max_min_value(df[var]) + return [data_min, data_max] + + +def get_original_column_values(df, var): + original_col = f"_{var}_original" + if original_col in df.columns: + return df[original_col].copy() + else: + return df[var].copy() + + +def calculate_daily_statistics(df, var_col, date_col=Variables.UTC_TIME.col_name): + if len(df) == 0: + return pd.DataFrame({"min": [], "max": [], "mean": []}) + + df_with_date = df.copy() + df_with_date["_date"] = df_with_date[date_col].dt.date + return df_with_date.groupby("_date")[var_col].agg(["min", "max", "mean"]) diff --git a/pages/natural_ventilation.py b/pages/natural_ventilation.py index 32c62cdb..8459bd49 100644 --- a/pages/natural_ventilation.py +++ b/pages/natural_ventilation.py @@ -1,8 +1,7 @@ -import math - import dash -from dash import dcc, html -import dash_bootstrap_components as dbc +from dash import dcc +from dash import no_update +import dash_mantine_components as dmc from dash_extensions.enrich import Output, Input, State, callback import numpy as np @@ -11,13 +10,13 @@ from config import PageUrls, DocLinks, PageInfo, UnitSystem from pages.lib.global_scheme import ( template, - mapping_dictionary, tight_margins, month_lst, - container_row_center_full, - container_col_center_one_of_three, ) -from pages.lib.template_graphs import filter_df_by_month_and_hour +from pages.lib.global_variables import Variables, VariableInfo +from pages.lib.global_element_ids import ElementIds +from pages.lib.global_id_buttons import IdButtons +from pages.lib.global_tab_names import TabNames from pages.lib.utils import ( title_with_tooltip, generate_chart_name, @@ -26,6 +25,7 @@ generate_custom_inputs_nv, determine_month_and_hour_filter, title_with_link, + separate_filtered_data, ) @@ -38,16 +38,20 @@ def layout(): - return html.Div( - className="container-col", - id="main-nv-section", - children=[ - # - ], + return dmc.Stack( + p="md", + children=dmc.Skeleton( # needed to avoid empty layout on load + visible=True, + height="100vh", + ), + id=ElementIds.MAIN_NV_SECTION, ) -@callback(Output("main-nv-section", "children"), [Input("si-ip-radio-input", "value")]) +@callback( + Output(ElementIds.MAIN_NV_SECTION, "children"), + [Input(ElementIds.SHARED_SI_IP_RADIO_INPUT, "value")], +) def update_layout(si_ip): if si_ip == UnitSystem.IP: tdb_set_min = 50 @@ -59,275 +63,195 @@ def update_layout(si_ip): dpt_set = 16 return [ - html.Div( - children=title_with_link( - text="Natural Ventilation Potential", - id_button="natural-ventilation-label", - doc_link=DocLinks.NATURAL_VENTILATION, - ), + title_with_link( + text="Natural Ventilation Potential", + id_button=IdButtons.NATURAL_VENTILATION_LABEL, + doc_link=DocLinks.NATURAL_VENTILATION, ), inputs_tab(tdb_set_min, tdb_set_max, dpt_set), - dcc.Loading( - html.Div( - id="nv-heatmap-chart", - style={"marginTop": "1rem"}, + dmc.Skeleton( + visible=False, + h=450, + children=dmc.Paper( + id=ElementIds.NV_HEATMAP_CHART, ), - type="circle", ), - html.Div( - className="container-row align-center justify-center", + dmc.Group( + justify="center", children=[ - dbc.Checklist( - options=[ - {"label": "", "value": 1}, - ], - value=[1], - id="switches-input", - switch=True, - style={ - "padding": "1rem", - "marginTop": "1rem", - "marginRight": "-2rem", - }, + dmc.Switch( + id=ElementIds.SWITCHES_INPUT, + label="", + checked=True, + color="blue", + style={"padding": "1rem", "marginRight": "-2rem"}, ), - html.Div( - children=title_with_tooltip( - text="Normalize data", - tooltip_text=( - "If normalized is enabled it calculates the % " - "time otherwise it calculates the total number of hours" - ), - id_button="nv_normalize", + title_with_tooltip( + text="Normalize data", + tooltip_text=( + "If normalized is enabled it calculates the % " + "time otherwise it calculates the total number of hours" ), + id_button=IdButtons.NV_NORMALIZE, ), ], ), - dcc.Loading( - html.Div( - id="nv-bar-chart", - style={"marginTop": "1rem"}, + dmc.Skeleton( + visible=False, + h=450, + children=dmc.Paper( + id=ElementIds.NV_BAR_CHART, ), - type="circle", ), ] def inputs_tab(t_min, t_max, d_set): - return html.Div( - className="container-row full-width three-inputs-container", + return dmc.Grid( + justify="center", children=[ - html.Div( - className=container_col_center_one_of_three, - children=[ - dbc.Button( - "Apply filter", - color="primary", - id="nv-dbt-filter", - className="mb-2", - n_clicks=1, - ), - html.H6("Outdoor dry-bulb air temperature range"), - html.Div( - className=container_row_center_full, - children=[ - html.H6(children=["Min Value:"], style={"flex": "30%"}), - dbc.Input( - id="nv-tdb-min-val", - placeholder="Enter a number for the min val", - type="number", - step=1, - value=t_min, - style={"flex": "70%"}, - ), - ], - ), - html.Div( - className=container_row_center_full, - children=[ - html.H6(children=["Max Value:"], style={"flex": "30%"}), - dbc.Input( - id="nv-tdb-max-val", - placeholder="Enter a number for the max val", - type="number", - value=t_max, - step=1, - style={"flex": "70%"}, - ), - ], - ), - ], - ), - html.Div( - className=container_col_center_one_of_three, - children=[ - dbc.Button( - "Apply month and hour filter", - color="primary", - id="nv-month-hour-filter", - className="mb-2", - n_clicks=0, - ), - html.Div( - className="container-row full-width justify-center mt-2", - children=[ - html.H6("Month Range", style={"flex": "20%"}), - html.Div( - dcc.RangeSlider( - id="nv-month-slider", - min=1, - max=12, + dmc.GridCol( + dmc.Stack( + [ + dmc.Title("Outdoor dry-bulb air temperature range", order=5), + dmc.Group( + [ + dmc.Title("Min Value:", order=5), + dmc.NumberInput( + id=ElementIds.NV_TDB_MIN_VAL, + placeholder="Enter a number for the min val", step=1, - value=[1, 12], - marks={1: "1", 12: "12"}, - tooltip={ - "always_visible": False, - "placement": "top", - }, - allowCross=False, + value=t_min, ), - style={"flex": "50%"}, - ), - dcc.Checklist( - options=[ - {"label": "Invert", "value": "invert"}, - ], - value=[], - id="invert-month-nv", - labelStyle={"flex": "30%"}, - ), - ], - ), - html.Div( - className="container-row align-center justify-center", - children=[ - html.H6("Hour Range", style={"flex": "20%"}), - html.Div( - dcc.RangeSlider( - id="nv-hour-slider", - min=0, - max=24, + ], + ), + dmc.Group( + [ + dmc.Title("Max Value:", order=5), + dmc.NumberInput( + id=ElementIds.NV_TDB_MAX_VAL, + placeholder="Enter a number for the max val", + value=t_max, step=1, - value=[0, 24], - marks={0: "0", 24: "24"}, - tooltip={ - "always_visible": False, - "placement": "topLeft", - }, - allowCross=False, ), - style={"flex": "50%"}, - ), - dcc.Checklist( - options=[ - {"label": "Invert", "value": "invert"}, - ], - value=[], - id="invert-hour-nv", - labelStyle={"flex": "30%"}, - ), - ], - ), - ], + ], + ), + dmc.Button( + "Apply filter", + color="blue", + id=ElementIds.NV_DBT_FILTER, + variant="link", + n_clicks=1, + w="80%", + ), + ] + ), + span={"base": 12, "md": 4}, ), - html.Div( - className=container_col_center_one_of_three, - children=[ - dbc.Button( - "Apply filter", - color="primary", - id="nv-dpt-filter", - className="mb-2", - n_clicks=0, - disabled=True, - ), - dbc.Checklist( - options=[ - { - "label": ( - "Avoid condensation with radiant systems: If the" - " outdoor dew point temperature is below the" - " radiant system surface temperature, the data" - " point is not plot." + dmc.GridCol( + dmc.Stack( + [ + dmc.Group( + [ + dmc.Title("Surface temperature:", order=5), + dmc.NumberInput( + id=ElementIds.NV_DPT_MAX_VAL, + placeholder="Enter a number for the max val", + value=d_set, + step=1, ), - "value": 1, - }, - ], - value=[], - id="enable-condensation", - ), - html.Div( - className=container_row_center_full, - children=[ - html.H6( - children=["Surface temperature:"], - style={"marginRight": "1rem"}, - ), - dbc.Input( - id="nv-dpt-max-val", - placeholder="Enter a number for the max val", - type="number", - value=d_set, - step=1, - style={"flex": "1"}, + ], + ), + dmc.Checkbox( + id=ElementIds.ENABLE_CONDENSATION, + label=( + "Avoid condensation with radiant systems: If the " + "outdoor dew point temperature is below the radiant " + "system surface temperature, the data point is not plot." ), - ], - ), - ], + checked=False, + size="sm", + w="70%", + ), + dmc.Button( + "Apply filter", + color="blue", + id=ElementIds.NV_DPT_FILTER, + variant="link", + disabled=True, + w="70%", + ), + ] + ), + span={"base": 12, "md": 5}, ), ], ) @callback( - Output("nv-heatmap-chart", "children"), + Output(ElementIds.NV_HEATMAP_CHART, "children"), [ - Input("df-store", "modified_timestamp"), - Input("nv-month-hour-filter", "n_clicks"), - Input("nv-dbt-filter", "n_clicks"), - Input("nv-dpt-filter", "n_clicks"), - Input("global-local-radio-input", "value"), - Input("enable-condensation", "value"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.NV_DBT_FILTER, "n_clicks"), + Input(ElementIds.NV_DPT_FILTER, "n_clicks"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.ENABLE_CONDENSATION, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("nv-month-slider", "value"), - State("nv-hour-slider", "value"), - State("nv-tdb-min-val", "value"), - State("nv-tdb-max-val", "value"), - State("nv-dpt-max-val", "value"), - State("meta-store", "data"), - State("invert-month-nv", "value"), - State("invert-hour-nv", "value"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.NV_TDB_MIN_VAL, "value"), + State(ElementIds.NV_TDB_MAX_VAL, "value"), + State(ElementIds.NV_DPT_MAX_VAL, "value"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) def nv_heatmap( ts, - time_filter, dbt_data_filter, click_dpt_filter, global_local, condensation_enabled, + global_filter_data, df, - month, - hour, min_dbt_val, max_dbt_val, max_dpt_val, meta, - invert_month, - invert_hour, si_ip, ): + if df is None: + return no_update # enable or disable button apply filter DPT dpt_data_filter = enable_dew_point_data_filter(condensation_enabled) - start_month, end_month, start_hour, end_hour = determine_month_and_hour_filter( - month, hour, invert_month, invert_hour - ) + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import ( + apply_global_month_hour_filter, + get_global_filter_state, + ) + + # Ensure DBT and DPT are included for filtering + target_columns = [Variables.DBT.col_name, Variables.DPT.col_name] + df = apply_global_month_hour_filter(df, global_filter_data, target_columns) - var = "DBT" - filter_var = "DPT" + filter_state = get_global_filter_state(global_filter_data) + month_range = filter_state["month_range"] + hour_range = filter_state["hour_range"] + invert_month_global = filter_state["invert_month"] + invert_hour_global = filter_state["invert_hour"] + + start_month, end_month, start_hour, end_hour = determine_month_and_hour_filter( + month_range, hour_range, invert_month_global, invert_hour_global + ) + else: + # Use default values when global filter is not active + start_month, end_month, start_hour, end_hour = 1, 12, 0, 24 + + var = Variables.DBT.col_name + filter_var = Variables.DPT.col_name if dbt_data_filter and (min_dbt_val <= max_dbt_val): df.loc[(df[var] < min_dbt_val) | (df[var] > max_dbt_val), var] = None @@ -335,47 +259,46 @@ def nv_heatmap( if dpt_data_filter: df.loc[(df[filter_var] < -200) | (df[filter_var] > max_dpt_val), var] = None - if df.dropna(subset=["month"]).shape[0] == 0: + if df.dropna(subset=[Variables.MONTH.col_name]).shape[0] == 0: return ( - dbc.Alert( - "Natural ventilation is not available in this location under these" - " conditions. Please either select a different outdoor dry-bulb air" - " temperature range, change the month and hour filter, or increase" - " thedew-point temperature.", - color="danger", + dmc.Alert( + title="Notice", + color="red", + children=( + "Natural ventilation is not available in this location under these " + "conditions. Please either select a different outdoor dry-bulb air " + "temperature range, change the month and hour filter, or increase " + "the dew-point temperature." + ), style={"text-align": "center", "marginTop": "2rem"}, ), ) - df = filter_df_by_month_and_hour( - df, time_filter, month, hour, invert_month, invert_hour, var - ) - - var_unit = mapping_dictionary[var][si_ip]["unit"] + variable = VariableInfo.from_col_name(var) + filter = VariableInfo.from_col_name(filter_var) - filter_unit = mapping_dictionary[filter_var][si_ip]["unit"] + var_unit = variable.get_unit(si_ip) - var_range = mapping_dictionary[var][si_ip]["range"] + filter_unit = filter.get_unit(si_ip) - var_name = mapping_dictionary[var]["name"] + var_name = variable.get_name() - filter_name = mapping_dictionary[filter_var]["name"] + filter_name = filter.get_name() - var_color = mapping_dictionary[var]["color"] + var_color = variable.get_color() - if global_local == "global": - range_z = var_range + if si_ip == UnitSystem.IP: + range_z = [32.0, 86.0] else: - data_max = 5 * math.ceil(df[var].max() / 5) - data_min = 5 * math.floor(df[var].min() / 5) - range_z = [data_min, data_max] + range_z = [0.0, 30.0] title = ( f"Hours when the {var_name} is in the range {min_dbt_val} to" f" {max_dbt_val} {var_unit}" ) - if time_filter: + # Title will be updated based on global filter state + if global_filter_data and global_filter_data.get("filter_active", False): title += ( f" between the months of {month_lst[start_month - 1]} and " f"{month_lst[end_month - 1]}
and between the hours {start_hour}" @@ -384,31 +307,147 @@ def nv_heatmap( if dpt_data_filter: title += f" and when the {filter_name} is below {max_dpt_val} {filter_unit}." - fig = go.Figure( - data=go.Heatmap( - y=df["hour"] - 0.5, # Offset by 0.5 to center the hour labels - x=df["UTC_time"].dt.date, - z=df[var], - colorscale=var_color, - zmin=range_z[0], - zmax=range_z[1], - connectgaps=False, - hoverongaps=False, - customdata=np.stack((df["month_names"], df["day"]), axis=-1), - hovertemplate=( - "" - + var - + ": %{z:.2f} " - + var_unit - + "
" - + "Month: %{customdata[0]}
" - + "Day: %{customdata[1]}
" - + "Hour: %{y}:00
" - ), - colorbar=dict(title=var_unit), - name="", + # Separate filtered and unfiltered data using utility function + filter_info = separate_filtered_data(df, var) + has_filter_marker = filter_info["has_filter_marker"] + filtered_mask = filter_info["filtered_mask"] + original_var_col = filter_info["original_var_col"] + use_original_for_filtered = filter_info["use_original_for_filtered"] + + fig = go.Figure() + + # Add filtered data trace (gray) if any filtered data exists + # Only show gray where there is actual data (not None), not in blank areas + if has_filter_marker and filtered_mask is not None and filtered_mask.any(): + if use_original_for_filtered: + # Use original DBT values for filtered data + filtered_values = df[original_var_col].copy() + # Apply DBT filter to original values to check if they're in range + if dbt_data_filter and (min_dbt_val <= max_dbt_val): + # Only show gray where original DBT is in range + in_range_mask = (filtered_values >= min_dbt_val) & ( + filtered_values <= max_dbt_val + ) + # Also check if DPT filter applies + if dpt_data_filter: + original_filter_var_col = f"_{filter_var}_original" + if original_filter_var_col in df.columns: + dpt_values = df[original_filter_var_col] + in_range_mask = ( + in_range_mask + & (dpt_values >= -200) + & (dpt_values <= max_dpt_val) + ) + else: + dpt_values = df[filter_var] + in_range_mask = ( + in_range_mask + & (dpt_values >= -200) + & (dpt_values <= max_dpt_val) + ) + filtered_values[~in_range_mask] = None + else: + filtered_values = df[var].copy() + + # Only show gray for filtered data points + filtered_values[~filtered_mask] = None + + # Only add trace if there are any valid filtered values + if filtered_values.notna().any(): + fig.add_trace( + go.Heatmap( + y=df[Variables.HOUR.col_name] - 0.5, + x=df[Variables.UTC_TIME.col_name].dt.date, + z=filtered_values, + colorscale=[[0, "lightgray"], [1, "gray"]], + zmin=range_z[0], + zmax=range_z[1], + showscale=False, + connectgaps=False, + hoverongaps=False, + customdata=np.stack( + ( + df[Variables.MONTH_NAMES.col_name], + df[Variables.DAY.col_name], + ), + axis=-1, + ), + hovertemplate=( + "Filtered Data
" + + var + + ": %{z:.2f} " + + var_unit + + "
" + + "Month: %{customdata[0]}
" + + "Day: %{customdata[1]}
" + + "Hour: %{y}:00
" + ), + name="filtered", + ) + ) + + # Add unfiltered data trace (normal color) + base_values = df[var].copy() + base_values[filtered_mask] = None + + fig.add_trace( + go.Heatmap( + y=df[Variables.HOUR.col_name] - 0.5, + x=df[Variables.UTC_TIME.col_name].dt.date, + z=base_values, + colorscale=var_color, + zmin=range_z[0], + zmax=range_z[1], + connectgaps=False, + hoverongaps=False, + customdata=np.stack( + (df[Variables.MONTH_NAMES.col_name], df[Variables.DAY.col_name]), + axis=-1, + ), + hovertemplate=( + "" + + var + + ": %{z:.2f} " + + var_unit + + "
" + + "Month: %{customdata[0]}
" + + "Day: %{customdata[1]}
" + + "Hour: %{y}:00
" + ), + colorbar=dict(title=var_unit), + name="", + ) + ) + else: + # No filtered data, use normal heatmap + fig.add_trace( + go.Heatmap( + y=df[Variables.HOUR.col_name] - 0.5, + x=df[Variables.UTC_TIME.col_name].dt.date, + z=df[var], + colorscale=var_color, + zmin=range_z[0], + zmax=range_z[1], + connectgaps=False, + hoverongaps=False, + customdata=np.stack( + (df[Variables.MONTH_NAMES.col_name], df[Variables.DAY.col_name]), + axis=-1, + ), + hovertemplate=( + "" + + var + + ": %{z:.2f} " + + var_unit + + "
" + + "Month: %{customdata[0]}
" + + "Day: %{customdata[1]}
" + + "Hour: %{y}:00
" + ), + colorbar=dict(title=var_unit), + name="", + ) ) - ) fig.update_layout( template=template, @@ -440,109 +479,316 @@ def nv_heatmap( ) units = generate_units_degree(si_ip) return dcc.Graph( - config=generate_chart_name("heatmap", meta, custom_inputs, units), + config=generate_chart_name(TabNames.HEATMAP, meta, custom_inputs, units), figure=fig, ) @callback( - Output("nv-bar-chart", "children"), + Output(ElementIds.NV_BAR_CHART, "children"), [ - Input("df-store", "modified_timestamp"), - Input("nv-month-hour-filter", "n_clicks"), - Input("nv-dbt-filter", "n_clicks"), - Input("nv-dpt-filter", "n_clicks"), - Input("switches-input", "value"), - Input("enable-condensation", "value"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.NV_DBT_FILTER, "n_clicks"), + Input(ElementIds.NV_DPT_FILTER, "n_clicks"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.SWITCHES_INPUT, "checked"), + Input(ElementIds.ENABLE_CONDENSATION, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("nv-month-slider", "value"), - State("nv-hour-slider", "value"), - State("nv-tdb-min-val", "value"), - State("nv-tdb-max-val", "value"), - State("nv-dpt-max-val", "value"), - State("meta-store", "data"), - State("invert-month-nv", "value"), - State("invert-hour-nv", "value"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.NV_TDB_MIN_VAL, "value"), + State(ElementIds.NV_TDB_MAX_VAL, "value"), + State(ElementIds.NV_DPT_MAX_VAL, "value"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) def nv_bar_chart( ts, - time_filter, dbt_data_filter, click_dpt_filter, + global_local, normalize, condensation_enabled, + global_filter_data, df, - month, - hour, min_dbt_val, max_dbt_val, max_dpt_val, meta, - invert_month, - invert_hour, si_ip, ): # enable or disable button apply filter DPT dpt_data_filter = enable_dew_point_data_filter(condensation_enabled) - start_month, end_month, start_hour, end_hour = determine_month_and_hour_filter( - month, hour, invert_month, invert_hour - ) + var = Variables.DBT.col_name + filter_var = Variables.DPT.col_name - var = "DBT" - filter_var = "DPT" + variable = VariableInfo.from_col_name(var) + filter = VariableInfo.from_col_name(filter_var) - var_unit = mapping_dictionary[var][si_ip]["unit"] - filter_unit = mapping_dictionary[filter_var][si_ip]["unit"] + var_unit = variable.get_unit(si_ip) + filter_unit = filter.get_unit(si_ip) - var_name = mapping_dictionary[var]["name"] + var_name = variable.get_name() - filter_name = mapping_dictionary[filter_var]["name"] + filter_name = filter.get_name() color_in = "dodgerblue" - df["nv_allowed"] = 1 + df[Variables.NV_ALLOWED.col_name] = 1 + + # Store original data info before applying global filter (to know which months originally had data) + if global_filter_data and global_filter_data.get("filter_active", False): + # Create a copy to check which months have data after DBT/DPT filtering (but before global filter) + df_temp = df.copy() + df_temp[Variables.NV_ALLOWED.col_name] = 1 + + # Apply DBT/DPT filters to the temporary copy + if dbt_data_filter and (min_dbt_val <= max_dbt_val): + df_temp.loc[ + (df_temp[var] < min_dbt_val) | (df_temp[var] > max_dbt_val), + Variables.NV_ALLOWED.col_name, + ] = 0 + + if dpt_data_filter: + df_temp.loc[ + (df_temp[filter_var] > max_dpt_val), Variables.NV_ALLOWED.col_name + ] = 0 + + # Check which months have data (NV_ALLOWED > 0) after DBT/DPT filtering + months_with_nv = df_temp[df_temp[Variables.NV_ALLOWED.col_name] > 0] + if len(months_with_nv) > 0: + set(months_with_nv[Variables.UTC_TIME.col_name].dt.month.unique()) + + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import ( + apply_global_month_hour_filter, + get_global_filter_state, + ) - df = filter_df_by_month_and_hour( - df, time_filter, month, hour, invert_month, invert_hour, "nv_allowed" - ) + # Include DBT and DPT in target_columns to preserve original values for filtered data + # Note: Do NOT include NV_ALLOWED in target_columns, as it will be set to None by time_filtering + # for filtered months, which would break the calculation of n_hours_nv_allowed_filtered + df = apply_global_month_hour_filter( + df, global_filter_data, [Variables.DBT.col_name, Variables.DPT.col_name] + ) - # this should be the total after filtering by time - tot_month_hours = df.groupby(df["UTC_time"].dt.month)["nv_allowed"].sum().values + filter_state = get_global_filter_state(global_filter_data) + month_range = filter_state["month_range"] + hour_range = filter_state["hour_range"] + invert_month_global = filter_state["invert_month"] + invert_hour_global = filter_state["invert_hour"] + start_month, end_month, start_hour, end_hour = determine_month_and_hour_filter( + month_range, hour_range, invert_month_global, invert_hour_global + ) + else: + # Use default values when global filter is not active + start_month, end_month, start_hour, end_hour = 1, 12, 0, 24 + + # Separate filtered and unfiltered data using utility function + filter_info = separate_filtered_data(df, Variables.DBT.col_name) + has_filter_marker = filter_info["has_filter_marker"] + filtered_mask = filter_info["filtered_mask"] + df_unfiltered = filter_info["df_unfiltered"] + df_filtered = filter_info["df_filtered"] + + # Calculate total hours per month (for both filtered and unfiltered) - ensure all 12 months are included + # This should be calculated BEFORE applying DBT/DPT filters, as it represents total hours in the selected time range + tot_month_hours_unfiltered = np.zeros(12) + tot_unfiltered_grouped = df_unfiltered.groupby( + df_unfiltered[Variables.UTC_TIME.col_name].dt.month + )[Variables.NV_ALLOWED.col_name].sum() + for month_idx in range(1, 13): + if month_idx in tot_unfiltered_grouped.index: + tot_month_hours_unfiltered[month_idx - 1] = tot_unfiltered_grouped[ + month_idx + ] + + # Calculate total filtered hours BEFORE applying DBT/DPT filters + # This represents all hours that were filtered by the global month/hour filter + # Simply count the number of rows in df_filtered for each month (each row = 1 hour) + tot_month_hours_filtered = np.zeros(12) + if df_filtered is not None and len(df_filtered) > 0: + # Count rows per month (each row represents 1 hour) + # This is the most reliable way as it doesn't depend on NV_ALLOWED being set + tot_filtered_grouped = df_filtered.groupby( + df_filtered[Variables.UTC_TIME.col_name].dt.month + ).size() + for month_idx in range(1, 13): + if month_idx in tot_filtered_grouped.index: + tot_month_hours_filtered[month_idx - 1] = tot_filtered_grouped[ + month_idx + ] + + # Apply DBT and DPT filters to unfiltered data if dbt_data_filter and (min_dbt_val <= max_dbt_val): - df.loc[(df[var] < min_dbt_val) | (df[var] > max_dbt_val), "nv_allowed"] = 0 + df_unfiltered.loc[ + (df_unfiltered[var] < min_dbt_val) | (df_unfiltered[var] > max_dbt_val), + Variables.NV_ALLOWED.col_name, + ] = 0 if dpt_data_filter: - df.loc[(df[filter_var] > max_dpt_val), "nv_allowed"] = 0 - - n_hours_nv_allowed = ( - df.dropna(subset="nv_allowed") - .groupby(df["UTC_time"].dt.month)["nv_allowed"] + df_unfiltered.loc[ + (df_unfiltered[filter_var] > max_dpt_val), Variables.NV_ALLOWED.col_name + ] = 0 + + # Apply DBT and DPT filters to filtered data (using original values if available) + if df_filtered is not None and len(df_filtered) > 0: + original_var_col = f"_{var}_original" + original_filter_var_col = f"_{filter_var}_original" + use_original_var = original_var_col in df_filtered.columns + use_original_filter_var = original_filter_var_col in df_filtered.columns + + if dbt_data_filter and (min_dbt_val <= max_dbt_val): + filter_var_to_use = original_var_col if use_original_var else var + df_filtered.loc[ + (df_filtered[filter_var_to_use] < min_dbt_val) + | (df_filtered[filter_var_to_use] > max_dbt_val), + Variables.NV_ALLOWED.col_name, + ] = 0 + + if dpt_data_filter: + filter_var_to_use = ( + original_filter_var_col if use_original_filter_var else filter_var + ) + df_filtered.loc[ + (df_filtered[filter_var_to_use] > max_dpt_val), + Variables.NV_ALLOWED.col_name, + ] = 0 + + # Calculate hours for unfiltered data - ensure all 12 months are included + n_hours_nv_allowed_unfiltered = np.zeros(12) + n_hours_unfiltered_grouped = ( + df_unfiltered.dropna(subset=Variables.NV_ALLOWED.col_name) + .groupby(df_unfiltered[Variables.UTC_TIME.col_name].dt.month)[ + Variables.NV_ALLOWED.col_name + ] .sum() - .values ) + for month_idx in range(1, 13): + if month_idx in n_hours_unfiltered_grouped.index: + n_hours_nv_allowed_unfiltered[month_idx - 1] = n_hours_unfiltered_grouped[ + month_idx + ] + + # Calculate hours for filtered data - ensure all 12 months are included + n_hours_nv_allowed_filtered = np.zeros(12) + if df_filtered is not None and len(df_filtered) > 0: + n_hours_filtered_grouped = ( + df_filtered.dropna(subset=Variables.NV_ALLOWED.col_name) + .groupby(df_filtered[Variables.UTC_TIME.col_name].dt.month)[ + Variables.NV_ALLOWED.col_name + ] + .sum() + ) + for month_idx in range(1, 13): + if month_idx in n_hours_filtered_grouped.index: + n_hours_nv_allowed_filtered[month_idx - 1] = n_hours_filtered_grouped[ + month_idx + ] + + # Calculate percentages - handle division by zero + per_time_nv_allowed_unfiltered = np.zeros(12) + for i in range(12): + if tot_month_hours_unfiltered[i] > 0: + per_time_nv_allowed_unfiltered[i] = np.round( + 100 * (n_hours_nv_allowed_unfiltered[i] / tot_month_hours_unfiltered[i]) + ) - per_time_nv_allowed = np.round(100 * (n_hours_nv_allowed / tot_month_hours)) + per_time_nv_allowed_filtered = np.zeros(12) + # Calculate percentages for all months where filtered hours exist + # Even if nv_allowed is 0, we should still show the gray bar (with 0% value) + for i in range(12): + if tot_month_hours_filtered[i] > 0: + per_time_nv_allowed_filtered[i] = np.round( + 100 * (n_hours_nv_allowed_filtered[i] / tot_month_hours_filtered[i]) + ) - if len(normalize) == 0: - fig = go.Figure( - go.Bar( - x=df["month_names"].unique(), - y=n_hours_nv_allowed, - name="", - marker_color=color_in, - customdata=np.stack((n_hours_nv_allowed, per_time_nv_allowed), axis=-1), + month_names = month_lst # Use month_lst to ensure all 12 months are included + traces = [] + + # Add filtered data traces (gray) if any filtered data exists + # For normalize mode: Show gray bars for months that have data but are outside the global filter range + # For non-normalize mode: Show gray bars for all filtered months + has_filtered_data = False + if has_filter_marker and filtered_mask is not None and filtered_mask.any(): + # Show gray bars if there are any filtered hours in any month + has_filtered_data = np.any(tot_month_hours_filtered > 0) + + if has_filtered_data: + if not normalize: + trace_filtered = go.Bar( + x=month_names, + y=n_hours_nv_allowed_filtered, + name="Natural Ventilation (Filtered)", + marker_color="gray", + customdata=np.stack( + (n_hours_nv_allowed_filtered, per_time_nv_allowed_filtered), axis=-1 + ), hovertemplate=( - "natural ventilation possible for:
%{customdata[0]} hrs or" + "Filtered Data
natural ventilation possible for:
%{customdata[0]} hrs or" "
%{customdata[1]}% of selected time
" ), ) + traces.append(trace_filtered) + else: + # For normalize mode: Show gray bars for months outside the global filter range + # Use actual percentage values, but for 0% values, use a minimal visible height (0.1%) + # so users can see that these months have filtered data + per_time_display_filtered = per_time_nv_allowed_filtered.copy() + + # Set None for months without filtered data (so they don't show gray bars) + # For months with filtered data but 0% NV, use 0.1% for minimal visibility + for i in range(12): + if tot_month_hours_filtered[i] == 0: + per_time_display_filtered[i] = None + elif per_time_nv_allowed_filtered[i] == 0: + # Use 0.1% for months with filtered data but 0% NV (very small but visible) + per_time_display_filtered[i] = 0.1 + + trace_filtered = go.Bar( + x=month_names, + y=per_time_display_filtered, + name="Natural Ventilation (Filtered)", + marker_color="gray", + marker_line_color="gray", + marker_line_width=1, + customdata=np.stack( + ( + n_hours_nv_allowed_filtered, + per_time_nv_allowed_filtered, + tot_month_hours_filtered, + ), + axis=-1, + ), + hovertemplate=( + "Filtered Data
natural ventilation possible for:
%{customdata[0]} hrs or
%{" + "customdata[1]:.2f}% of filtered time range
Total filtered hours: %{customdata[2]:.0f}
" + ), + base=0, + opacity=0.8, + ) + traces.append(trace_filtered) + + # Add unfiltered data traces (normal colors) + if not normalize: + trace_unfiltered = go.Bar( + x=month_names, + y=n_hours_nv_allowed_unfiltered, + name="Natural Ventilation", + marker_color=color_in, + customdata=np.stack( + (n_hours_nv_allowed_unfiltered, per_time_nv_allowed_unfiltered), axis=-1 + ), + hovertemplate=( + "natural ventilation possible for:
%{customdata[0]} hrs or" + "
%{customdata[1]}% of selected time
" + ), ) + traces.append(trace_unfiltered) title = ( f"Number of hours the {var_name}" @@ -550,22 +796,21 @@ def nv_bar_chart( + " to " + f" {max_dbt_val} {var_unit}" ) - fig.update_yaxes(title_text="hours", range=[0, 744]) - else: - trace1 = go.Bar( - x=df["month_names"].unique(), - y=per_time_nv_allowed, - name="", + trace_unfiltered = go.Bar( + x=month_names, + y=per_time_nv_allowed_unfiltered, + name="Natural Ventilation", marker_color=color_in, - customdata=np.stack((n_hours_nv_allowed, per_time_nv_allowed), axis=-1), + customdata=np.stack( + (n_hours_nv_allowed_unfiltered, per_time_nv_allowed_unfiltered), axis=-1 + ), hovertemplate=( "natural ventilation possible for:
%{customdata[0]} hrs or
%{" "customdata[1]}% of selected time
" ), ) - - fig = go.Figure(data=trace1) + traces.append(trace_unfiltered) title = ( f"Percentage of hours the {var_name}" @@ -573,9 +818,15 @@ def nv_bar_chart( + f" to {max_dbt_val}" + f" {var_unit}" ) + + fig = go.Figure(data=traces) + + if not normalize: + fig.update_yaxes(title_text="hours", range=[0, 744]) + else: fig.update_yaxes(title_text="Percentage (%)", range=[0, 100]) - if time_filter: + if global_filter_data and global_filter_data.get("filter_active", False): title += ( f" between the months of {month_lst[start_month - 1]} and " f"{month_lst[end_month - 1]} and between
the hours {start_hour}" @@ -584,12 +835,15 @@ def nv_bar_chart( if dpt_data_filter: title += f" when the {filter_name} is below {max_dpt_val} {filter_unit}." + # Use barmode="relative" to show filtered and unfiltered bars side by side + # Only use relative mode if we have filtered data (non-normalize mode only) fig.update_layout( template=template, title=title, barnorm="", dragmode=False, margin=tight_margins.copy().update({"t": 55}), + barmode="relative" if has_filtered_data else "group", ) fig.update_xaxes( @@ -604,21 +858,24 @@ def nv_bar_chart( ) units = generate_units(si_ip) return dcc.Graph( - config=generate_chart_name("barchart", meta, custom_inputs, units), + config=generate_chart_name(TabNames.BARCHART, meta, custom_inputs, units), figure=fig, ) -@callback(Output("nv-dpt-filter", "disabled"), Input("enable-condensation", "value")) -def enable_disable_button_data_filter(state_checklist): - if len(state_checklist) == 1: +@callback( + Output(ElementIds.NV_DPT_FILTER, "disabled"), + Input(ElementIds.ENABLE_CONDENSATION, "checked"), +) +def enable_disable_button_data_filter(state_checkbox): + if state_checkbox: return False else: return True def enable_dew_point_data_filter(condensation_enabled): - if len(condensation_enabled) == 1: + if condensation_enabled: return True else: return False diff --git a/pages/not_found_404.py b/pages/not_found_404.py index ff3b389c..9fc307e5 100644 --- a/pages/not_found_404.py +++ b/pages/not_found_404.py @@ -13,7 +13,7 @@ layout = [ dmc.Title("I could not find the page you are currently looking for", order=4), - dmc.Text("Use the button below to return to the home page.", className="mb-2"), + dmc.Text("Use the button below to return to the home page.", mb="sm"), Lottie( options=dict( loop=True, @@ -27,7 +27,7 @@ dmc.Button( "Home page", fullWidth=True, - leftIcon=DashIconify(icon="material-symbols:home-outline-rounded"), + leftSection=DashIconify(icon="material-symbols:home-outline-rounded"), ), href=PageUrls.SELECT.value, ), diff --git a/pages/outdoor.py b/pages/outdoor.py index ab8d53e1..d60b38fb 100644 --- a/pages/outdoor.py +++ b/pages/outdoor.py @@ -1,11 +1,15 @@ import dash from dash import dcc, html -import dash_bootstrap_components as dbc +import dash_mantine_components as dmc from dash_extensions.enrich import Output, Input, State, callback import numpy as np from config import PageUrls, DocLinks, PageInfo +from pages.lib.global_element_ids import ElementIds +from pages.lib.global_variables import Variables +from pages.lib.global_id_buttons import IdButtons +from pages.lib.global_tab_names import TabNames from pages.lib.global_scheme import ( outdoor_dropdown_names, ) @@ -21,6 +25,7 @@ title_with_link, title_with_tooltip, ) +from pages.lib.utils import get_time_filter_from_store dash.register_page( @@ -32,193 +37,94 @@ def inputs_outdoor_comfort(): - return dbc.Row( - className="container-row full-width three-inputs-container", - children=[ - dbc.Col( - md=6, - sm=12, - children=[ - html.Div( - className="container-row center-block", - children=[ - html.H4( - children=["Select a scenario:"], - style={"flex": "30%"}, - ), - dropdown( - id="tab7-dropdown", - style={"flex": "60%"}, - options=outdoor_dropdown_names, - value="utci_Sun_Wind", - ), - html.Div(id="image-selection", style={"flex": "10%"}), - ], - ), - ], - ), - dbc.Col( - md=6, - sm=12, - children=[ - dbc.Button( - "Apply month and hour filter", - color="primary", - style={ - "width": "100%", - }, - id="month-hour-filter-outdoor-comfort", - className="mb-2", - n_clicks=0, - ), - html.Div( - className="container-row full-width justify-center mt-2", - children=[ - html.H6("Month Range", style={"flex": "5%"}), - html.Div( - dcc.RangeSlider( - id="outdoor-comfort-month-slider", - min=1, - max=12, - step=1, - value=[1, 12], - marks={1: "1", 12: "12"}, - tooltip={ - "always_visible": False, - "placement": "top", - }, - allowCross=False, - ), - style={"flex": "50%"}, - ), - dcc.Checklist( - options=[ - {"label": "Invert", "value": "invert"}, - ], - value=[], - id="invert-month-outdoor-comfort", - labelStyle={"flex": "30%"}, - ), - ], - ), - html.Div( - className="container-row align-center justify-center", - children=[ - html.H6("Hour Range", style={"flex": "5%"}), - html.Div( - dcc.RangeSlider( - id="outdoor-comfort-hour-slider", - min=0, - max=24, - step=1, - value=[0, 24], - marks={0: "0", 24: "24"}, - tooltip={ - "always_visible": False, - "placement": "topLeft", - }, - allowCross=False, - ), - style={"flex": "50%"}, - ), - dcc.Checklist( - options=[ - {"label": "Invert", "value": "invert"}, - ], - value=[], - id="invert-hour-outdoor-comfort", - labelStyle={"flex": "30%"}, - ), - ], - ), - ], + return dmc.Group( + [ + dmc.Title("Select a scenario:", order=5), + dropdown( + id=ElementIds.OUTDOOR_DROPDOWN, + options=outdoor_dropdown_names, + value="utci_Sun_Wind", + persistence=True, + persistence_type="session", ), + dmc.Paper(id=ElementIds.IMAGE_SELECTION), ], + gap="xs", + justify="center", ) def outdoor_comfort_chart(): - return html.Div( + return dmc.Stack( children=[ - html.Div(id="outdoor-comfort-output"), - html.Div( - children=title_with_link( - text="UTCI heatmap chart", - id_button="utci-charts-label", - doc_link=DocLinks.UTCI_CHART, - ) + dmc.Title(id=ElementIds.OUTDOOR_COMFORT_OUTPUT, order=4), + title_with_link( + text="UTCI heatmap chart", + id_button=IdButtons.UTCI_CHARTS_LABEL, + doc_link=DocLinks.UTCI_CHART, ), - dcc.Loading( - html.Div(id="utci-heatmap"), - type="circle", + dmc.Skeleton( + visible=False, + h=450, + children=dmc.Paper( + id=ElementIds.UTCI_HEATMAP, + ), ), - html.Div( - children=title_with_link( - text="UTCI thermal stress chart", - id_button="utci-charts-label", - doc_link=DocLinks.UTCI_CHART, - ) + title_with_link( + text="UTCI thermal stress chart", + id_button=IdButtons.UTCI_CHARTS_LABEL, + doc_link=DocLinks.UTCI_CHART, ), - dcc.Loading( - html.Div(id="utci-category-heatmap"), - type="circle", + dmc.Skeleton( + visible=False, + h=450, + children=dmc.Paper( + id=ElementIds.UTCI_CATEGORY_HEATMAP, + ), ), - html.Div( - className="container-row align-center justify-center", + dmc.Group( + justify="center", children=[ - dbc.Checklist( - options=[ - {"label": "", "value": 1}, - ], - value=[1], - id="outdoor-comfort-switches-input", - switch=True, - style={ - "padding": "1rem", - "marginTop": "1rem", - "marginRight": "-2rem", - }, + dmc.Switch( + id=ElementIds.OUTDOOR_COMFORT_SWITCHES_INPUT, + checked=True, + color="blue", ), - html.Div( - children=title_with_tooltip( - text="Normalize data", - tooltip_text=( - "If normalized is enabled it calculates the % " - "time otherwise it calculates the total number of hours" - ), - id_button="outdoor-comfort-normalize", + title_with_tooltip( + text="Normalize data", + tooltip_text=( + "If normalized is enabled it calculates the % time " + "otherwise it calculates the total number of hours" ), + id_button=IdButtons.OUTDOOR_COMFORT_NORMALIZE, ), ], ), - dcc.Loading( - html.Div(id="utci-summary-chart"), - type="circle", + dmc.Skeleton( + visible=False, + h=450, + children=dmc.Paper( + id=ElementIds.UTCI_SUMMARY_CHART, + ), ), ], ) def layout(): - return ( - dcc.Loading( - type="circle", - children=html.Div( - className="container-col", - children=[inputs_outdoor_comfort(), outdoor_comfort_chart()], - ), - ), + return dmc.Stack( + p="md", + children=[ + inputs_outdoor_comfort(), + outdoor_comfort_chart(), + ], ) @callback( - Output("outdoor-comfort-output", "children"), - [ - Input("df-store", "modified_timestamp"), - ], - [ - State("df-store", "data"), - ], + Output(ElementIds.OUTDOOR_COMFORT_OUTPUT, "children"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + State(ElementIds.SHARED_DF_STORE, "data"), ) def update_outdoor_comfort_output(_, df): """ @@ -234,10 +140,10 @@ def update_outdoor_comfort_output(_, df): Description of the best weather condition(s). """ cols = [ - "utci_noSun_Wind_categories", - "utci_noSun_noWind_categories", - "utci_Sun_Wind_categories", - "utci_Sun_noWind_categories", + Variables.UTCI_NOSUN_WIND_CATEGORIES.col_name, + Variables.UTCI_NOSUN_NOWIND_CATEGORIES.col_name, + Variables.UTCI_SUN_WIND_CATEGORIES.col_name, + Variables.UTCI_SUN_NOWIND_CATEGORIES.col_name, ] cols_with_the_highest_number_of_zero = [] highest_count = 0 @@ -253,44 +159,61 @@ def update_outdoor_comfort_output(_, df): cols_with_the_highest_number_of_zero.append(col) elif count == highest_count: cols_with_the_highest_number_of_zero.append(col) - return f"The Best Weather Condition is: {', '.join(cols_with_the_highest_number_of_zero)}" + + # Convert column names to display names using string replacement + display_names = [] + for col in cols_with_the_highest_number_of_zero: + # Remove utci_ prefix and replace all underscores with spaces + display_name = col.replace("utci_", "UTCI ") + display_name = display_name.replace("_", " ") + display_name = display_name.replace("categories", "Categories") + # Fix specific words that need spaces + display_name = display_name.replace("noSun", "No Sun") + display_name = display_name.replace("noWind", "No Wind") + display_name = display_name.replace("Sun", "Sun") # Keep Sun as is + display_name = display_name.replace("Wind", "Wind") # Keep Wind as is + display_names.append(display_name) + + return f"The Best Weather Condition is: {', '.join(display_names)}" @callback( - Output("utci-heatmap", "children"), + Output(ElementIds.UTCI_HEATMAP, "children"), [ - Input("df-store", "modified_timestamp"), - Input("tab7-dropdown", "value"), - Input("global-local-radio-input", "value"), - Input("month-hour-filter-outdoor-comfort", "n_clicks"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.OUTDOOR_DROPDOWN, "value"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), - State("outdoor-comfort-month-slider", "value"), - State("outdoor-comfort-hour-slider", "value"), - State("invert-month-outdoor-comfort", "value"), - State("invert-hour-outdoor-comfort", "value"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) def update_tab_utci_value( _, var, global_local, - time_filter, + global_filter_data, df, meta, si_ip, - month, - hour, - invert_month, - invert_hour, ): + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + df = apply_global_month_hour_filter(df, global_filter_data, var) + + # Normalize filter state (handles inactive case with defaults) + time_filter, month, hour, invert_month, invert_hour = get_time_filter_from_store( + global_filter_data if global_filter_data else None + ) + custom_inputs = f"{var}" units = generate_units_degree(si_ip) return dcc.Graph( - config=generate_chart_name("heatmap", meta, custom_inputs, units), + config=generate_chart_name(TabNames.HEATMAP, meta, custom_inputs, units), figure=heatmap_with_filter( df, var, @@ -307,8 +230,8 @@ def update_tab_utci_value( @callback( - Output("image-selection", "children"), - Input("tab7-dropdown", "value"), + Output(ElementIds.IMAGE_SELECTION, "children"), + Input(ElementIds.OUTDOOR_DROPDOWN, "value"), ) def change_image_based_on_selection(value): if value == "utci_Sun_Wind": @@ -324,36 +247,39 @@ def change_image_based_on_selection(value): @callback( - Output("utci-category-heatmap", "children"), + Output(ElementIds.UTCI_CATEGORY_HEATMAP, "children"), [ - Input("df-store", "modified_timestamp"), - Input("tab7-dropdown", "value"), - Input("global-local-radio-input", "value"), - Input("month-hour-filter-outdoor-comfort", "n_clicks"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.OUTDOOR_DROPDOWN, "value"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), - State("outdoor-comfort-month-slider", "value"), - State("outdoor-comfort-hour-slider", "value"), - State("invert-month-outdoor-comfort", "value"), - State("invert-hour-outdoor-comfort", "value"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) def update_tab_utci_category( _, var, global_local, - time_filter, + global_filter_data, df, meta, si_ip, - month, - hour, - invert_month, - invert_hour, ): + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + df = apply_global_month_hour_filter( + df, global_filter_data, [var, var + "_categories"] + ) + + time_filter, month, hour, invert_month, invert_hour = get_time_filter_from_store( + global_filter_data if global_filter_data else None + ) + utci_stress_cat = heatmap_with_filter( df, var + "_categories", @@ -366,11 +292,13 @@ def update_tab_utci_category( invert_hour, "UTCI thermal stress", ) - utci_stress_cat["data"][0]["colorbar"] = dict( + colorbar_index = 1 if len(utci_stress_cat["data"]) > 1 else 0 + + utci_stress_cat["data"][colorbar_index]["colorbar"] = dict( title="Thermal stress", titleside="top", tickmode="array", - tickvals=np.linspace(4.75, -4.75, 10), + tickvals=np.linspace(4, -5, 10), ticktext=[ "extreme heat stress", "very strong heat stress", @@ -388,31 +316,41 @@ def update_tab_utci_category( custom_inputs = f"{var}" units = generate_units(si_ip) return dcc.Graph( - config=generate_chart_name("heatmap_category", meta, custom_inputs, units), + config=generate_chart_name( + TabNames.HEATMAP_CATEGORY, meta, custom_inputs, units + ), figure=utci_stress_cat, ) @callback( - Output("utci-summary-chart", "children"), + Output(ElementIds.UTCI_SUMMARY_CHART, "children"), [ - Input("tab7-dropdown", "value"), - Input("month-hour-filter-outdoor-comfort", "n_clicks"), - Input("outdoor-comfort-switches-input", "value"), + Input(ElementIds.OUTDOOR_DROPDOWN, "value"), + Input(ElementIds.OUTDOOR_COMFORT_SWITCHES_INPUT, "checked"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("outdoor-comfort-month-slider", "value"), - State("outdoor-comfort-hour-slider", "value"), - State("meta-store", "data"), - State("invert-month-outdoor-comfort", "value"), - State("invert-hour-outdoor-comfort", "value"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) -def update_tab_utci_summary_chart( - var, time_filter, normalize, df, month, hour, meta, invert_month, invert_hour, si_ip -): +def update_tab_utci_summary_chart(var, normalize, global_filter_data, df, meta, si_ip): + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + # Include both the original UTCI variable and the categories column + # so we can preserve original values for filtered data + df = apply_global_month_hour_filter( + df, global_filter_data, [var, var + "_categories"] + ) + + # Unified filter state for both active and inactive cases + time_filter, month, hour, invert_month, invert_hour = get_time_filter_from_store( + global_filter_data if global_filter_data else None + ) + utci_summary_chart = thermal_stress_stacked_barchart( df, var + "_categories", @@ -427,6 +365,6 @@ def update_tab_utci_summary_chart( custom_inputs = f"{var}" units = generate_units(si_ip) return dcc.Graph( - config=generate_chart_name("summary", meta, custom_inputs, units), + config=generate_chart_name(TabNames.SUMMARY, meta, custom_inputs, units), figure=utci_summary_chart, ) diff --git a/pages/psy-chart.py b/pages/psy-chart.py index 2c46b885..51f63667 100644 --- a/pages/psy-chart.py +++ b/pages/psy-chart.py @@ -1,8 +1,6 @@ -from math import ceil, floor - import dash -from dash import dcc, html -import dash_bootstrap_components as dbc +from dash import dcc +import dash_mantine_components as dmc from dash_extensions.enrich import Output, Input, State, callback from copy import deepcopy @@ -12,15 +10,17 @@ from pythermalcomfort import psychrometrics as psy from config import PageUrls, DocLinks, PageInfo, UnitSystem +from pages.lib.utils import get_max_min_value, separate_filtered_data +from pages.lib.global_element_ids import ElementIds +from pages.lib.global_variables import Variables, VariableInfo +from pages.lib.global_id_buttons import IdButtons +from pages.lib.global_tab_names import TabNames from pages.lib.global_scheme import ( - container_row_center_full, - container_col_center_one_of_three, dropdown_names, sun_cloud_tab_dropdown_names, more_variables_dropdown, sun_cloud_tab_explore_dropdown_names, template, - mapping_dictionary, tight_margins, ) from pages.lib.template_graphs import filter_df_by_month_and_hour @@ -34,6 +34,13 @@ ) +def _safe_get_column(df, column_name, default_value=0): + if column_name in df.columns: + return df[column_name] + else: + return [default_value] * len(df) + + dash.register_page( __name__, name=PageInfo.PSYCHROMETRIC_NAME, @@ -56,228 +63,151 @@ def inputs(): - """""" - return html.Div( - className="container-row full-width three-inputs-container", + return dmc.Grid( + justify="center", children=[ - html.Div( - className=container_col_center_one_of_three, - children=[ - html.Div( - className=container_row_center_full, - children=[ - html.H6( - children=["Color By:"], - style={"flex": "30%"}, - ), - dropdown( - id="psy-color-by-dropdown", - options=psy_dropdown_names, - value="Frequency", - style={"flex": "70%"}, - persistence_type="session", - persistence=True, - ), - ], + dmc.GridCol( + [ + dmc.Title("Color By:", order=5), + dropdown( + id=ElementIds.PSY_COLOR_BY_DROPDOWN, + options=psy_dropdown_names, + value="Frequency", + persistence=True, + persistence_type="session", ), ], + span={"base": 12, "md": 4}, ), - html.Div( - className=container_col_center_one_of_three, - children=[ - dbc.Button( - "Apply month and hour filter", - color="primary", - id="month-hour-filter", - className="mb-2", - n_clicks=0, - ), - html.Div( - className="container-row full-width justify-center mt-2", - children=[ - html.H6("Month Range", style={"flex": "20%"}), - html.Div( - dcc.RangeSlider( - id="psy-month-slider", - min=1, - max=12, + dmc.GridCol( + dmc.Stack( + [ + dmc.Group( + [ + dmc.Title("Filter Variable:", order=5), + dropdown( + id=ElementIds.PSY_VAR_DROPDOWN, + options=dropdown_names, + value=Variables.RH.col_name, + ), + ], + ), + dmc.Group( + [ + dmc.Title("Min Value:", order=5), + dmc.NumberInput( + id=ElementIds.PSY_MIN_VAL, + placeholder="Enter a number for the min val", + value=0, step=1, - value=[1, 12], - marks={1: "1", 12: "12"}, - tooltip={ - "always_visible": False, - "placement": "top", - }, - allowCross=False, ), - style={"flex": "50%"}, - ), - dcc.Checklist( - options=[ - {"label": "Invert", "value": "invert"}, - ], - value=[], - id="invert-month-psy", - labelStyle={"flex": "30%"}, - ), - ], - ), - html.Div( - className="container-row align-center justify-center", - children=[ - html.H6("Hour Range", style={"flex": "20%"}), - html.Div( - dcc.RangeSlider( - id="psy-hour-slider", - min=0, - max=24, + ], + ), + dmc.Group( + [ + dmc.Title("Max Value:", order=5), + dmc.NumberInput( + id=ElementIds.PSY_MAX_VAL, + placeholder="Enter a number for the max val", + value=100, step=1, - value=[0, 24], - marks={0: "0", 24: "24"}, - tooltip={ - "always_visible": False, - "placement": "topLeft", - }, - allowCross=False, ), - style={"flex": "50%"}, - ), - dcc.Checklist( - options=[ - {"label": "Invert", "value": "invert"}, - ], - value=[], - id="invert-hour-psy", - labelStyle={"flex": "30%"}, - ), - ], - ), - ], - ), - html.Div( - className=container_col_center_one_of_three, - children=[ - dbc.Button( - "Apply filter", - color="primary", - id="data-filter", - className="mb-2", - n_clicks=0, - ), - html.Div( - className=container_row_center_full, - children=[ - html.H6( - children=["Filter Variable:"], style={"flex": "30%"} - ), - dropdown( - id="psy-var-dropdown", - options=dropdown_names, - value="RH", - style={"flex": "70%"}, - ), - ], - ), - html.Div( - className=container_row_center_full, - children=[ - html.H6(children=["Min Value:"], style={"flex": "30%"}), - dbc.Input( - id="psy-min-val", - placeholder="Enter a number for the min val", - type="number", - step=1, - value=0, - style={"flex": "70%"}, - ), - ], - ), - html.Div( - className=container_row_center_full, - children=[ - html.H6(children=["Max Value:"], style={"flex": "30%"}), - dbc.Input( - id="psy-max-val", - placeholder="Enter a number for the max val", - type="number", - value=100, - step=1, - style={"flex": "70%"}, - ), - ], - ), - ], + ], + ), + dmc.Button( + "Apply filter", + id=ElementIds.DATA_FILTER, + color="blue", + w="50%", + ), + ], + ), + span={"base": 12, "md": 4}, ), ], ) def layout(): - return ( - html.Div( - children=title_with_link( + return dmc.Stack( + p="md", + children=[ + title_with_link( text="Psychrometric Chart", - id_button="Psychrometric-Chart-chart", + id_button=IdButtons.PSYCHROMETRIC_CHART_CHART, doc_link=DocLinks.PSYCHROMETRIC_CHART, ), - ), - dcc.Loading( - type="circle", - children=html.Div( - className="container-col", - children=[inputs(), html.Div(id="psych-chart")], + inputs(), + dmc.Skeleton( + visible=False, + h=450, + id=ElementIds.PSYCH_CHART, ), - ), + ], ) -# psychrometric chart @callback( - Output("psych-chart", "children"), + Output(ElementIds.PSYCH_CHART, "children"), [ - Input("df-store", "modified_timestamp"), - Input("psy-color-by-dropdown", "value"), - Input("month-hour-filter", "n_clicks"), - Input("data-filter", "n_clicks"), - Input("global-local-radio-input", "value"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.PSY_COLOR_BY_DROPDOWN, "value"), + Input(ElementIds.DATA_FILTER, "n_clicks"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("psy-month-slider", "value"), - State("psy-hour-slider", "value"), - State("psy-min-val", "value"), - State("psy-max-val", "value"), - State("psy-var-dropdown", "value"), - State("meta-store", "data"), - State("invert-month-psy", "value"), - State("invert-hour-psy", "value"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.PSY_MIN_VAL, "value"), + State(ElementIds.PSY_MAX_VAL, "value"), + State(ElementIds.PSY_VAR_DROPDOWN, "value"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) def update_psych_chart( ts, colorby_var, - time_filter, data_filter, global_local, + global_filter_data, df, - month, - hour, min_val, max_val, data_filter_var, meta, - invert_month, - invert_hour, si_ip, ): - start_month, end_month, start_hour, end_hour = determine_month_and_hour_filter( - month, hour, invert_month, invert_hour - ) + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import ( + apply_global_month_hour_filter, + get_global_filter_state, + ) - df = filter_df_by_month_and_hour( - df, time_filter, month, hour, invert_month, invert_hour, df.columns - ) + # Determine which columns to filter - need DBT and HR at minimum, plus colorby_var if it's not None/Frequency + target_columns = [Variables.DBT.col_name, Variables.HR.col_name] + if colorby_var not in ["None", "Frequency"]: + target_columns.append(colorby_var) + if data_filter and data_filter_var: + target_columns.append(data_filter_var) + + df = apply_global_month_hour_filter(df, global_filter_data, target_columns) + + filter_state = get_global_filter_state(global_filter_data) + month_range = filter_state["month_range"] + hour_range = filter_state["hour_range"] + invert_month_global = filter_state["invert_month"] + invert_hour_global = filter_state["invert_hour"] + + start_month, end_month, start_hour, end_hour = determine_month_and_hour_filter( + month_range, hour_range, invert_month_global, invert_hour_global + ) + else: + # Use default values when global filter is not active + start_month, end_month, start_hour, end_hour = 1, 12, 0, 24 + + # Use local filtering when global filter is not active + df = filter_df_by_month_and_hour(df, True, [1, 12], [0, 24], [], [], df.columns) if data_filter: if min_val <= max_val: @@ -287,9 +217,9 @@ def update_psych_chart( mask = (df[data_filter_var] >= max_val) & (df[data_filter_var] <= min_val) df[mask] = None - if df.dropna(subset=["month"]).shape[0] == 0: + if df.dropna(subset=[Variables.MONTH.col_name]).shape[0] == 0: return ( - dbc.Alert( + dmc.Alert( "No data is available in this location under these conditions. Please " "either change the month and hour filters, or select a wider range for " "the filter variable", @@ -304,25 +234,27 @@ def update_psych_chart( elif var == "Frequency": var_color = ["rgba(255,255,255,0)", "rgb(0,150,255)", "rgb(0,0,150)"] else: - var_unit = mapping_dictionary[var][si_ip]["unit"] + var_unit = VariableInfo.from_col_name(var).get_unit(si_ip) - var_name = mapping_dictionary[var]["name"] + var_name = VariableInfo.from_col_name(var).get_name() - var_color = mapping_dictionary[var]["color"] + var_color = VariableInfo.from_col_name(var).get_color() if global_local == "global": # Set Global values for Max and minimum - var_range_x = mapping_dictionary["DBT"][si_ip]["range"] - var_range_y = mapping_dictionary["hr"][si_ip]["range"] + variable_x = VariableInfo.from_col_name(Variables.DBT.col_name) + variable_y = VariableInfo.from_col_name(Variables.HR.col_name) + + var_range_x = variable_x.get_range(si_ip) + var_range_y = variable_y.get_range(si_ip) else: # Set maximum and minimum according to data - data_max = 5 * ceil(df["DBT"].max() / 5) - data_min = 5 * floor(df["DBT"].min() / 5) + data_max, data_min = get_max_min_value(df[Variables.DBT.col_name]) var_range_x = [data_min, data_max] - data_max = round(df["hr"].max(), 4) - data_min = round(df["hr"].min(), 4) + data_max = round(df[Variables.HR.col_name].max(), 4) + data_min = round(df[Variables.HR.col_name].min(), 4) var_range_y = [data_min * 1000, data_max * 1000] title = "Psychrometric Chart" @@ -338,7 +270,7 @@ def update_psych_chart( hr_list = np.vectorize(psy.psy_ta_rh)(dbt_list, rh) hr_df = pd.DataFrame.from_records(hr_list) name = "rh" + str(rh) - rh_df[name] = hr_df["hr"] + rh_df[name] = hr_df[Variables.HR.col_name] fig = go.Figure() @@ -368,114 +300,137 @@ def update_psych_chart( ) ) - df_hr_multiply = list(df["hr"]) - for k in range(len(df_hr_multiply)): - df_hr_multiply[k] = df_hr_multiply[k] * 1000 - if var == "None": - fig.add_trace( - go.Scatter( - x=df["DBT"], - y=df_hr_multiply, - showlegend=False, - mode="markers", - marker=dict( - size=6, - color=var_color, - showscale=False, - opacity=0.2, - ), - hovertemplate=mapping_dictionary["DBT"]["name"] - + ": %{x:.2f}" - + mapping_dictionary["DBT"]["name"], - name="", + # Separate filtered and unfiltered data using utility function + # Note: psy-chart needs to check multiple original columns (DBT, HR, and var) + filter_info = separate_filtered_data(df, Variables.DBT.col_name) + df_unfiltered = filter_info["df_unfiltered"] + + # Process HR for unfiltered data + df_unfiltered_hr_multiply = list(df_unfiltered[Variables.HR.col_name]) + for k in range(len(df_unfiltered_hr_multiply)): + df_unfiltered_hr_multiply[k] = df_unfiltered_hr_multiply[k] * 1000 + + # Filtered data traces removed - no gray filtering effect for psychrometric chart + + # Add unfiltered data traces (normal colors) + if len(df_unfiltered) > 0: + if var == "None": + fig.add_trace( + go.Scatter( + x=df_unfiltered[Variables.DBT.col_name], + y=df_unfiltered_hr_multiply, + showlegend=False, + mode="markers", + marker=dict( + size=6, + color=var_color, + showscale=False, + opacity=0.2, + ), + hovertemplate=VariableInfo.from_col_name( + Variables.DBT.col_name + ).get_name() + + ": %{x:.2f}" + + VariableInfo.from_col_name(Variables.DBT.col_name).get_unit( + si_ip + ), + name="", + ) ) - ) - elif var == "Frequency": - fig.add_trace( - go.Histogram2d( - x=df["DBT"], - y=df_hr_multiply, - name="", - colorscale=var_color, - hovertemplate="", - autobinx=False, - xbins=dict(start=-50, end=100, size=1), + elif var == "Frequency": + fig.add_trace( + go.Histogram2d( + x=df_unfiltered[Variables.DBT.col_name], + y=df_unfiltered_hr_multiply, + name="", + colorscale=var_color, + hovertemplate="", + autobinx=False, + xbins=dict(start=-50, end=100, size=1), + ) ) - ) - # fig.add_trace( - # go.Scatter( - # x=dbt_list, - # y=rh_df["rh100"], - # showlegend=False, - # mode="none", - # name="", - # fill="toself", - # fillcolor="#fff", - # ) - # ) + # Filtered data removed - no gray filtering effect for psychrometric chart - else: - var_colorbar = dict( - thickness=30, - title=var_unit + "
", - ) - - if var_unit == "Thermal stress": - var_colorbar["tickvals"] = [4, 3, 2, 1, 0, -1, -2, -3, -4, -5] - var_colorbar["ticktext"] = [ - "extreme heat stress", - "very strong heat stress", - "strong heat stress", - "moderate heat stress", - "no thermal stress", - "slight cold stress", - "moderate cold stress", - "strong cold stress", - "very strong cold stress", - "extreme cold stress", - ] + else: + var_colorbar = dict( + thickness=30, + title=var_unit + "
", + ) - fig.add_trace( - go.Scatter( - x=df["DBT"], - y=df_hr_multiply, - showlegend=False, - mode="markers", - marker=dict( - size=5, - color=df[var], - showscale=True, - opacity=0.3, - colorscale=var_color, - colorbar=var_colorbar, - ), - customdata=np.stack((df["RH"], df["h"], df[var], df["t_dp"]), axis=-1), - hovertemplate=mapping_dictionary["DBT"]["name"] - + ": %{x:.2f}" - + mapping_dictionary["DBT"][si_ip]["unit"] - + "
" - + mapping_dictionary["RH"]["name"] - + ": %{customdata[0]:.2f}" - + mapping_dictionary["RH"][si_ip]["unit"] - + "
" - + mapping_dictionary["h"]["name"] - + ": %{customdata[1]:.2f}" - + mapping_dictionary["h"][si_ip]["unit"] - + "
" - + mapping_dictionary["t_dp"]["name"] - + ": %{customdata[3]:.2f}" - + mapping_dictionary["t_dp"][si_ip]["unit"] - + "
" - + "
" - + var_name - + ": %{customdata[2]:.2f}" - + var_unit, - name="", + if var_unit == "Thermal stress": + var_colorbar["tickvals"] = [4, 3, 2, 1, 0, -1, -2, -3, -4, -5] + var_colorbar["ticktext"] = [ + "extreme heat stress", + "very strong heat stress", + "strong heat stress", + "moderate heat stress", + "no thermal stress", + "slight cold stress", + "moderate cold stress", + "strong cold stress", + "very strong cold stress", + "extreme cold stress", + ] + + fig.add_trace( + go.Scatter( + x=df_unfiltered[Variables.DBT.col_name], + y=df_unfiltered_hr_multiply, + showlegend=False, + mode="markers", + marker=dict( + size=5, + color=df_unfiltered[var], + showscale=True, + opacity=0.3, + colorscale=var_color, + colorbar=var_colorbar, + ), + customdata=np.stack( + ( + df_unfiltered[Variables.RH.col_name], + df_unfiltered["h"], + df_unfiltered[var], + df_unfiltered["t_dp"], + ), + axis=-1, + ), + hovertemplate=VariableInfo.from_col_name( + Variables.DBT.col_name + ).get_name() + + ": %{x:.2f}" + + VariableInfo.from_col_name(Variables.DBT.col_name).get_unit(si_ip) + + "
" + + VariableInfo.from_col_name(Variables.RH.col_name).get_name() + + ": %{customdata[0]:.2f}" + + VariableInfo.from_col_name(Variables.RH.col_name).get_unit(si_ip) + + "
" + + VariableInfo.from_col_name("h").get_name() + + ": %{customdata[1]:.2f}" + + VariableInfo.from_col_name("h").get_unit(si_ip) + + "
" + + VariableInfo.from_col_name("t_dp").get_name() + + ": %{customdata[3]:.2f}" + + VariableInfo.from_col_name("t_dp").get_unit(si_ip) + + "
" + + "
" + + var_name + + ": %{customdata[2]:.2f}" + + var_unit, + name="", + ) ) - ) - xtitle_name = "Temperature" + " " + mapping_dictionary["DBT"][si_ip]["unit"] - ytitle_name = "Humidity Ratio" + " " + mapping_dictionary["hr"][si_ip]["unit"] + xtitle_name = ( + "Temperature" + + " " + + VariableInfo.from_col_name(Variables.DBT.col_name).get_unit(si_ip) + ) + ytitle_name = ( + "Humidity Ratio" + + " " + + VariableInfo.from_col_name(Variables.HR.col_name).get_unit(si_ip) + ) fig.update_layout(template=template, margin=tight_margins) fig.update_xaxes( title_text=xtitle_name, @@ -505,5 +460,5 @@ def update_psych_chart( ) units = generate_units(si_ip) return dcc.Graph( - config=generate_chart_name("psy", meta, custom_inputs, units), figure=fig + config=generate_chart_name(TabNames.PSY, meta, custom_inputs, units), figure=fig ) diff --git a/pages/select.py b/pages/select.py index d4e4af13..3be207c6 100644 --- a/pages/select.py +++ b/pages/select.py @@ -3,7 +3,6 @@ import re import dash -import dash_bootstrap_components as dbc import dash_mantine_components as dmc import pandas as pd import plotly.express as px @@ -11,11 +10,13 @@ from dash_extensions.enrich import Serverside, Output, Input, State, html, dcc, callback from pandas import json_normalize -from pages.lib.extract_df import convert_data +from pages.lib.extract_df import convert_df_units from pages.lib.extract_df import create_df, get_data, get_location_info -from pages.lib.global_scheme import mapping_dictionary -from config import PageUrls, PageInfo, UnitSystem -from pages.lib.utils import generate_chart_name +from pages.lib.global_variables import Variables +from pages.lib.global_element_ids import ElementIds +from pages.lib.global_tab_names import TabNames +from config import PageUrls, PageInfo +from pages.lib.utils import generate_chart_name, get_default_global_filter_store_data dash.register_page( __name__, @@ -36,60 +37,69 @@ def layout(): """Contents in the first tab 'Select Weather File'""" - return html.Div( - className="container-col tab-container", + return dmc.Stack( + p="md", children=[ dcc.Loading( - id="loading-1", - type="circle", + custom_spinner=dmc.Skeleton(visible=True, h="100%"), fullscreen=True, children=alert(), ), dcc.Upload( - id="upload-data", - children=dbc.Button( + id=ElementIds.UPLOAD_DATA, + children=dmc.Button( [ "Drag and Drop or ", html.A("Select an EPW file from your computer"), ], - id="upload-data-button", - outline=True, - color="secondary", - className="mt-2", - style={"borderRadius": "5px", "borderStyle": "dashed"}, + id=ElementIds.UPLOAD_DATA_BUTTON, + variant="outline", + color="gray", + style={"borderStyle": "dashed"}, + styles={"label": {"fontWeight": 400}}, ), # Allow multiple files to be uploaded multiple=True, - className="d-grid", + style={"display": "grid"}, ), dmc.Skeleton( visible=False, - id="skeleton-graph-container", + id=ElementIds.SKELETON_GRAPH_CONTAINER, height=500, - children=html.Div(id="tab-one-map"), + children=dmc.Box(id=ElementIds.TAB_ONE_MAP), ), - dbc.Modal( - [ - dbc.ModalHeader(id="modal-header"), - dbc.ModalFooter( - children=[ - dbc.Button( + dmc.Modal( + id=ElementIds.MODAL, + title=dmc.Text(id=ElementIds.MODAL_HEADER), + opened=False, + centered=True, + children=[ + dmc.Divider( + size="xs", + color="gray", + my="sm", + style={ + "borderTop": "1px solid var(--mantine-color-gray-4)", + "marginTop": "-6px", + }, + ), + dmc.Group( + [ + dmc.Button( "Close", - id="modal-close-button", - className="ml-2", - color="light", + id=ElementIds.MODAL_CLOSE_BUTTON, + color="gray", + variant="outline", ), - dbc.Button( + dmc.Button( "Yes", - id="modal-yes-button", - className="ml-2", - color="primary", + id=ElementIds.MODAL_YES_BUTTON, + color="blue", ), - ] + ], + justify="flex-end", ), ], - id="modal", - is_open=False, ), ], ) @@ -97,12 +107,11 @@ def layout(): def alert(): """Alert layout for the submit button.""" - return dbc.Alert( + return dmc.Alert( messages_alert["start"], - color="primary", - id="alert", - dismissable=False, - is_open=True, + color="blue", + id=ElementIds.ALERT, + withCloseButton=False, style={"maxHeight": "66px"}, ) @@ -110,20 +119,21 @@ def alert(): # add si-ip and map dictionary in the output @callback( [ - Output("meta-store", "data"), - Output("lines-store", "data"), - Output("alert", "is_open"), - Output("alert", "children"), - Output("alert", "color"), + Output(ElementIds.SHARED_META_STORE, "data"), + Output(ElementIds.SHARED_LINES_STORE, "data"), + Output(ElementIds.ALERT, "visible"), + Output(ElementIds.ALERT, "children"), + Output(ElementIds.ALERT, "color"), + Output(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data", allow_duplicate=True), ], [ - Input("modal-yes-button", "n_clicks"), - Input("upload-data-button", "n_clicks"), - Input("upload-data", "contents"), + Input(ElementIds.MODAL_YES_BUTTON, "n_clicks"), + Input(ElementIds.UPLOAD_DATA_BUTTON, "n_clicks"), + Input(ElementIds.UPLOAD_DATA, "contents"), ], [ - State("upload-data", "filename"), - State("url-store", "data"), + State(ElementIds.UPLOAD_DATA, "filename"), + State(ElementIds.SHARED_URL_STORE, "data"), ], prevent_initial_call=True, ) @@ -138,7 +148,7 @@ def submitted_data( """Process the uploaded file or download the EPW from the URL""" ctx = dash.callback_context - if ctx.triggered[0]["prop_id"] == "modal-yes-button.n_clicks": + if ctx.triggered[0][Variables.PROP_ID.col_name] == "modal-yes-button.n_clicks": lines = get_data(url_store) if lines is None: return ( @@ -146,7 +156,8 @@ def submitted_data( None, True, messages_alert["not_available"], - "warning", + "orange", + get_default_global_filter_store_data(), ) location_info = get_location_info( lines, url_store @@ -156,11 +167,12 @@ def submitted_data( lines, True, messages_alert["success"], - "success", + "green", + get_default_global_filter_store_data(), ) elif ( - ctx.triggered[0]["prop_id"] == "upload-data.contents" + ctx.triggered[0][Variables.PROP_ID.col_name] == "upload-data.contents" and list_of_contents is not None ): content_type, content_string = list_of_contents[0].split(",") @@ -180,7 +192,8 @@ def submitted_data( lines, True, messages_alert["success"], - "success", + "green", + get_default_global_filter_store_data(), ) else: return ( @@ -188,7 +201,8 @@ def submitted_data( None, True, messages_alert["invalid_format"], - "warning", + "orange", + get_default_global_filter_store_data(), ) except (ValueError, IndexError, KeyError) as e: print(f"Error parsing EPW file: {e}") @@ -197,7 +211,8 @@ def submitted_data( None, True, messages_alert["wrong_extension"], - "warning", + "orange", + get_default_global_filter_store_data(), ) raise PreventUpdate @@ -205,21 +220,24 @@ def submitted_data( # add switch_si_ip function and convert the data-store @callback( [ - Output("df-store", "data"), - Output("si-ip-unit-store", "data"), + Output(ElementIds.SHARED_DF_STORE, "data"), + Output(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), + ], + [ + Input(ElementIds.SHARED_LINES_STORE, "modified_timestamp"), + Input(ElementIds.SHARED_SI_IP_RADIO_INPUT, "value"), ], [ - Input("lines-store", "modified_timestamp"), - Input("si-ip-radio-input", "value"), + State(ElementIds.SHARED_URL_STORE, "data"), + State(ElementIds.SHARED_LINES_STORE, "data"), ], - [State("url-store", "data"), State("lines-store", "data")], ) def switch_si_ip(_, si_ip_input, url_store, lines): if lines is not None: df, _ = create_df(lines, url_store) - map_json = json.dumps(mapping_dictionary) - if si_ip_input == UnitSystem.IP: - map_json = convert_data(df, map_json) + + df = convert_df_units(df, si_ip_input) + return Serverside(df), si_ip_input else: return ( @@ -230,20 +248,19 @@ def switch_si_ip(_, si_ip_input, url_store, lines): @callback( [ - Output("/", "disabled"), - Output("/summary", "disabled"), - Output("/t-rh", "disabled"), - Output("/sun", "disabled"), - Output("/wind", "disabled"), - Output("/psy-chart", "disabled"), - Output("/explorer", "disabled"), - Output("/outdoor", "disabled"), - Output("/natural-ventilation", "disabled"), - Output("banner-subtitle", "children"), + Output(ElementIds.NAV_SUMMARY, "disabled"), + Output(ElementIds.NAV_T_RH, "disabled"), + Output(ElementIds.NAV_SUN, "disabled"), + Output(ElementIds.NAV_WIND, "disabled"), + Output(ElementIds.NAV_PSY_CHART, "disabled"), + Output(ElementIds.NAV_EXPLORER, "disabled"), + Output(ElementIds.NAV_OUTDOOR, "disabled"), + Output(ElementIds.NAV_NATURAL_VENTILATION, "disabled"), + Output(ElementIds.ID_SELECT_BANNER_SUBTITLE, "children"), ], [ - Input("meta-store", "data"), - Input("df-store", "data"), + Input(ElementIds.SHARED_META_STORE, "data"), + Input(ElementIds.SHARED_DF_STORE, "data"), ], ) def enable_tabs_when_data_is_loaded(meta, data): @@ -259,7 +276,6 @@ def enable_tabs_when_data_is_loaded(meta, data): True, True, True, - True, default, ) else: @@ -272,53 +288,50 @@ def enable_tabs_when_data_is_loaded(meta, data): False, False, False, - False, - "Current Location: " + meta["city"] + ", " + meta["country"], + "Current Location: " + + meta[Variables.CITY.col_name] + + ", " + + meta[Variables.COUNTRY.col_name], ) @callback( [ - Output("modal", "is_open"), - Output("url-store", "data"), + Output(ElementIds.MODAL, "opened"), + Output(ElementIds.SHARED_URL_STORE, "data"), ], [ - Input("modal-yes-button", "n_clicks"), - Input("tab-one-map", "clickData"), - Input("modal-close-button", "n_clicks"), + Input(ElementIds.MODAL_YES_BUTTON, "n_clicks"), + Input(ElementIds.TAB_ONE_MAP, "clickData"), + Input(ElementIds.MODAL_CLOSE_BUTTON, "n_clicks"), ], - [State("modal", "is_open")], + [State(ElementIds.MODAL, "opened")], prevent_initial_call=True, ) -def display_modal_when_data_clicked(_, click_map, __, is_open): +def display_modal_when_data_clicked(_, click_map, __, opened): """display the modal to the user and check if he wants to use that file""" if click_map: url = re.search( r'href=[\'"]?([^\'" >]+)', click_map["points"][0]["customdata"][-1] ).group(1) - return not is_open, url - return is_open, "" + return not opened, url + return opened, "" @callback( - [ - Output("modal-header", "children"), - ], - [ - Input("tab-one-map", "clickData"), - ], + [Output(ElementIds.MODAL_HEADER, "children")], + [Input(ElementIds.TAB_ONE_MAP, "clickData")], prevent_initial_call=True, ) def change_text_modal(click_map): - """change the text of the modal header""" if click_map: return [f"Analyse data from {click_map['points'][0]['hovertext']}?"] return ["Analyse data from this location?"] @callback( - Output("skeleton-graph-container", "children"), - Input("url", "pathname"), + Output(ElementIds.SKELETON_GRAPH_CONTAINER, "children"), + Input(ElementIds.SELECT_URL, "pathname"), ) def plot_location_epw_files(pathname): # print(pathname) @@ -328,9 +341,11 @@ def plot_location_epw_files(pathname): with open("./assets/data/epw_location.json", encoding="utf8") as data_file: data = json.load(data_file) - df = json_normalize(data["features"]) - df[["lon", "lat"]] = pd.DataFrame(df["geometry.coordinates"].tolist()) - df["lat"] += 0.010 + df = json_normalize(data[Variables.FEATURES.col_name]) + df[[Variables.LON.col_name, Variables.LAT.col_name]] = pd.DataFrame( + df[Variables.GEOMETRY_COORDINATES.col_name].tolist() + ) + df[Variables.LAT.col_name] += 0.010 df = df.rename(columns={"properties.epw": "Source"}) fig = px.scatter_mapbox( @@ -349,7 +364,7 @@ def plot_location_epw_files(pathname): df_one_building, lat="lat", lon="lon", - hover_name=df_one_building["name"], + hover_name=df_one_building[Variables.NAME.col_name], color_discrete_sequence=["#4895ef"], hover_data=[ "period", @@ -366,10 +381,8 @@ def plot_location_epw_files(pathname): fig.update_layout(mapbox_style="carto-positron") fig.update_layout(margin={"r": 0, "t": 0, "l": 0, "b": 0}) - return ( - dcc.Graph( - id="tab-one-map", - figure=fig, - config=generate_chart_name("epw_location_select"), - ), + return dcc.Graph( + id=ElementIds.TAB_ONE_MAP, + figure=fig, + config=generate_chart_name(TabNames.EPW_LOCATION_SELECT), ) diff --git a/pages/summary.py b/pages/summary.py index 733056ad..3d77e165 100644 --- a/pages/summary.py +++ b/pages/summary.py @@ -1,16 +1,20 @@ import dash -import dash_bootstrap_components as dbc -from dash.exceptions import PreventUpdate -from dash_extensions.enrich import dcc, html, Output, Input, State, callback - +import pandas as pd +import dash_mantine_components as dmc import plotly.graph_objects as go import requests +from dash.exceptions import PreventUpdate +from dash_extensions.enrich import dcc, Output, Input, State, callback from config import PageUrls, DocLinks, PageInfo, UnitSystem from pages.lib.charts_summary import world_map from pages.lib.extract_df import get_data -from pages.lib.global_scheme import template, tight_margins, mapping_dictionary +from pages.lib.global_scheme import template, tight_margins from pages.lib.template_graphs import violin +from pages.lib.global_variables import Variables, VariableInfo +from pages.lib.global_element_ids import ElementIds +from pages.lib.global_id_buttons import IdButtons +from pages.lib.global_tab_names import TabNames from pages.lib.utils import ( generate_chart_name, generate_units, @@ -31,17 +35,19 @@ def layout(): """Contents in the second tab 'Climate Summary'.""" - return html.Div( - className="container-col", - id="tab-two-container", - children=[ - # - ], + return dmc.Stack( + id=ElementIds.TAB_TWO_CONTAINER, + p="md", + children=dmc.Skeleton( # needed to avoid empty layout on load + visible=True, + height="100vh", + ), ) @callback( - Output("tab-two-container", "children"), [Input("si-ip-radio-input", "value")] + Output(ElementIds.TAB_TWO_CONTAINER, "children"), + [Input(ElementIds.SHARED_SI_IP_RADIO_INPUT, "value")], ) def update_layout(si_ip): if si_ip == UnitSystem.SI: @@ -51,134 +57,116 @@ def update_layout(si_ip): heating_setpoint = 50 cooling_setpoint = 64 - return html.Div( - className="container-col", - id="tab2-sec1-container", + return dmc.Stack( + id=ElementIds.SUMMARY_SCE1_CONTAINER, children=[ - dcc.Loading( - type="circle", - children=html.Div( - className="container-col", - id="location-info", - style={"padding": "12px"}, + dmc.Skeleton( + visible=False, + children=dmc.Stack( + id=ElementIds.LOCATION_INFO, + children=[dmc.Text("info")] + * 10, # placeholder text for height calc + gap=0, ), ), - dcc.Loading( - type="circle", - children=html.Div(className="tab-two-section", id="world-map"), + dmc.Skeleton( + visible=False, + h=300, + children=dmc.Stack(id=ElementIds.WORLD_MAP), ), - html.Div( - children=title_with_tooltip( - text="Download", - id_button="download-button-label", - tooltip_text="Use the following buttons to download either the Clima sourcefile or the EPW file", - ), + title_with_tooltip( + text="Download", + id_button=IdButtons.DOWNLOAD_BUTTON_LABEL, + tooltip_text="Use the following buttons to download either the Clima sourcefile or the EPW file", ), - dcc.Loading( - type="circle", - children=dbc.Row( - [ - dbc.Col( - dbc.Button( - "Download EPW", - color="primary", - id="download-epw-button", - ), - width="auto", + dmc.Skeleton( + visible=False, + children=dmc.Group( + children=[ + dmc.Button( + "Download EPW", + id=ElementIds.DOWN_EPW_BUTTON, + color="blue", + variant="filled", ), - dbc.Col( - dbc.Button( - "Download Clima dataframe", - color="primary", - id="download-button", - ), - width="auto", - ), - dbc.Col( - [ - dcc.Download(id="download-dataframe-csv"), - dcc.Download(id="download-epw"), - ], - width=1, + dmc.Button( + "Download Clima dataframe", + id=ElementIds.DOWNLOAD_BUTTON, + color="blue", + variant="filled", ), + dcc.Download(id=ElementIds.DOWNLOAD_DATAFRAME_CSV), + dcc.Download(id=ElementIds.DOWNLOAD_EPW), ], ), ), - html.Div( - children=title_with_link( - text="Heating and Cooling Degree Days", - id_button="hdd-cdd-chart", - doc_link=DocLinks.DEGREE_DAYS, - ), - ), - dbc.Alert( - "WARNING: Invalid Results! The CDD setpoint should be higher than the HDD setpoint!", - color="warning", - is_open=False, - id="warning-cdd-higher-hdd", + title_with_link( + text="Heating and Cooling Degree Days", + id_button=IdButtons.HDD_CDD_CHART, + doc_link=DocLinks.DEGREE_DAYS, ), - dbc.Row( - [ - dbc.Col( - html.Label( - "Heating degree day (HDD) setpoint", - ), - width="auto", - ), - dbc.Col( - dbc.Input( - id="input-hdd-set-point", - type="number", - value=heating_setpoint, - style={"width": "4rem"}, - ), - width="auto", + dmc.Stack(id=ElementIds.WARNING_CDD_HIGHER_HDD), + dmc.Group( + justify="center", + children=[ + dmc.Text("Heating degree day (HDD) setpoint"), + dmc.NumberInput( + id=ElementIds.INPUT_HDD_SET_POINT, + value=heating_setpoint, + step=1, + min=-100, + max=100, + w=80, + hideControls=False, ), - dbc.Col( - html.Label( - "Cooling degree day (CDD) setpoint", - ), - width="auto", + dmc.Text("Cooling degree day (CDD) setpoint"), + dmc.NumberInput( + id=ElementIds.INPUT_CDD_SET_POINT, + value=cooling_setpoint, + step=1, + min=-100, + max=100, + w=80, + hideControls=False, ), - dbc.Col( - dbc.Input( - id="input-cdd-set-point", - type="number", - value=cooling_setpoint, - style={"width": "4rem"}, - ), - width="auto", - ), - dbc.Col( - dbc.Button( - id="submit-set-points", - children="Submit", - color="primary", - ), - width="auto", + dmc.Button( + id=ElementIds.SUBMIT_SET_POINTS, + children="Submit", + color="blue", + variant="filled", ), ], - align="center", - justify="center", ), - dcc.Loading( - type="circle", - children=html.Div(id="degree-days-chart-wrapper"), + dmc.Skeleton( + visible=False, + h=450, + children=dmc.Stack(id=ElementIds.DEGREE_DAYS_CHART_WRAPPER), ), - html.Div( - children=title_with_link( - text="Climate Profiles", - id_button="climate-profiles-chart", - doc_link=DocLinks.CLIMATE_PROFILES, - ), + title_with_link( + text="Climate Profiles", + id_button=IdButtons.CLIMATE_PROFILES_CHART, + doc_link=DocLinks.CLIMATE_PROFILES, ), - dbc.Row( - id="graph-container", + dmc.Grid( + id=ElementIds.GRAPH_CONTAINER, + gutter="md", children=[ - dbc.Col(id="temp-profile-graph", width=12, md=6, lg=3), - dbc.Col(id="humidity-profile-graph", width=12, md=6, lg=3), - dbc.Col(id="solar-radiation-graph", width=12, md=6, lg=3), - dbc.Col(id="wind-speed-graph", width=12, md=6, lg=3), + dmc.GridCol( + id=ElementIds.TEMP_PROFILE_GRAPH, + span={"base": 12, "sm": 6, "lg": 3}, + ), + dmc.GridCol( + id=ElementIds.HUMIDITY_PROFILE_GRAPH, + span={"base": 12, "sm": 6, "lg": 3}, + ), + dmc.GridCol( + id=ElementIds.SOLAR_RADIATION_GRAPH, + span={"base": 12, "sm": 6, "lg": 3}, + ), + dmc.GridCol( + id=ElementIds.WIND_SPEED_GRAPH, + span={"base": 12, "sm": 6, "lg": 3}, + ), ], ), ], @@ -197,325 +185,414 @@ def update_layout(si_ip): @callback( - Output("world-map", "children"), - Input("meta-store", "data"), + Output(ElementIds.WORLD_MAP, "children"), + Input(ElementIds.SHARED_META_STORE, "data"), ) def update_map(meta): """Update the contents of tab two. Passing in the general info (df, meta).""" - map_world = dcc.Graph( - id="gh_rad-profile-graph", - config=generate_chart_name("map", meta), + return dcc.Graph( + config=generate_chart_name(TabNames.MAP, meta), figure=world_map(meta), ) - return map_world - @callback( - Output("location-info", "children"), - Input("df-store", "modified_timestamp"), + Output(ElementIds.LOCATION_INFO, "children"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) def update_location_info(ts, df, meta, si_ip): """Update the contents of tab two. Passing in the general info (df, meta).""" - location = f"Location: {meta['city']}, {meta['country']}" - lon = f"Longitude: {meta['lon']}" - lat = f"Latitude: {meta['lat']}" + location = ( + f"Location: {meta[Variables.CITY.col_name]}, {meta[Variables.COUNTRY.col_name]}" + ) + lon = f" Longitude: {meta[Variables.LON.col_name]}" + lat = f"Latitude: {meta[Variables.LAT.col_name]}" - site_elevation = float(meta["site_elevation"]) - site_elevation = round(site_elevation, 2) + site_elevation = round(float(meta[Variables.SITE_ELEVATION.col_name]), 2) if si_ip != UnitSystem.SI: - site_elevation = site_elevation * 3.281 - site_elevation = round(site_elevation, 2) - elevation = f"Elevation above sea level: {str(site_elevation)} ft" + site_elevation = round(site_elevation * 3.281, 2) + elevation = f"Elevation above sea level: {site_elevation} ft" + else: - elevation = f"Elevation above sea level: {meta['site_elevation']} m" + elevation = f"Elevation above sea level: {site_elevation} m" + period = "" - if meta["period"]: - start, stop = meta["period"].split("-") + if meta[Variables.PERIOD.col_name]: + start, stop = meta[Variables.PERIOD.col_name].split("-") period = f"This file is based on data collected between {start} and {stop}" - r = requests.get( - f"http://climateapi.scottpinkelman.com/api/v1/location/{meta['lat']}/{meta['lon']}" - ) - climate_text = "" - if r.status_code == 200: - try: - climate_zone = r.json()["return_values"][0]["koppen_geiger_zone"] - zone_description = r.json()["return_values"][0]["zone_description"] - - climate_text = ( - f"Köppen–Geiger climate zone: {climate_zone}. {zone_description}." - ) - except KeyError: - pass + try: + r = requests.get( + f"http://climateapi.scottpinkelman.com/api/v1/location/{meta[Variables.LAT.col_name]}/{meta[Variables.LON.col_name]}" + ) + if r.status_code == 200: + j = r.json()["return_values"][0] + climate_text = f"Köppen-Geiger climate zone: {j['koppen_geiger_zone']}. {j['zone_description']}." + except Exception: + pass # global horizontal irradiance # Note that the value is divided by 1000, so a corresponding change is made in the unit: - total_solar_rad_value = round(df["glob_hor_rad"].sum() / 1000, 2) - total_solar_rad_unit = "k" + mapping_dictionary["glob_hor_rad"][si_ip]["unit"] + total_solar_rad_value = round(df[Variables.GLOB_HOR_RAD.col_name].sum() / 1000, 2) + total_solar_rad_unit = "k" + VariableInfo.from_col_name( + Variables.GLOB_HOR_RAD.col_name + ).get_unit(si_ip).replace("", "").replace("", "") total_solar_rad = f"Annual cumulative horizontal solar radiation: {total_solar_rad_value} {total_solar_rad_unit}" - glob_sum = df["glob_hor_rad"].sum() - if glob_sum > 0: - diffuse_percentage = round(df["dif_hor_rad"].sum() / glob_sum * 100, 1) - else: - diffuse_percentage = 0 + glob_sum = df[Variables.GLOB_HOR_RAD.col_name].sum() + diffuse_percentage = ( + round(df[Variables.DIF_HOR_RAD.col_name].sum() / glob_sum * 100, 1) + if glob_sum > 0 + else 0 + ) total_diffuse_rad = ( f"Percentage of diffuse horizontal solar radiation: {diffuse_percentage} %" ) - tmp_unit = mapping_dictionary["DBT"][si_ip]["unit"] - average_yearly_tmp = ( - f"Average yearly temperature: {df['DBT'].mean().round(1)} " + tmp_unit - ) - hottest_yearly_tmp = ( - f"Hottest yearly temperature (99%): {df['DBT'].quantile(0.99).round(1)} " - + tmp_unit - ) - coldest_yearly_tmp = ( - f"Coldest yearly temperature (1%): {df['DBT'].quantile(0.01).round(1)} " - + tmp_unit - ) - location_info = dbc.Col( - [ - dbc.Row(location, style={"fontWeight": "bold"}), - dbc.Row(lon), - dbc.Row(lat), - dbc.Row(elevation), - dbc.Row(period), - dbc.Row(climate_text), - dbc.Row(average_yearly_tmp), - dbc.Row(hottest_yearly_tmp), - dbc.Row(coldest_yearly_tmp), - dbc.Row( - dcc.Markdown( - dangerously_allow_html=True, - children=[total_solar_rad], - style={"padding": 0}, - ) - ), - dbc.Row(total_diffuse_rad), - ], - ) + tmp_unit = VariableInfo.from_col_name(Variables.DBT.col_name).get_unit(si_ip) + + average_yearly_tmp = f"Average yearly temperature: {df[Variables.DBT.col_name].mean().round(1)} {tmp_unit}" + hottest_yearly_tmp = f"Hottest yearly temperature (99%): {df[Variables.DBT.col_name].quantile(0.99).round(1)} {tmp_unit}" + coldest_yearly_tmp = f"Coldest yearly temperature (1%): {df[Variables.DBT.col_name].quantile(0.01).round(1)} {tmp_unit}" - return location_info + return [ + dmc.Text(location, fw=700), + dmc.Text(lon), + dmc.Text(lat), + dmc.Text(elevation), + dmc.Text(period) if period else None, + dmc.Text(climate_text) if climate_text else None, + dmc.Text(average_yearly_tmp), + dmc.Text(hottest_yearly_tmp), + dmc.Text(coldest_yearly_tmp), + dmc.Text(total_solar_rad), + dmc.Text(total_diffuse_rad), + ] @callback( [ - Output("degree-days-chart-wrapper", "children"), - Output("warning-cdd-higher-hdd", "is_open"), + Output(ElementIds.DEGREE_DAYS_CHART_WRAPPER, "children"), + Output(ElementIds.WARNING_CDD_HIGHER_HDD, "is-open"), ], [ - Input("df-store", "modified_timestamp"), - Input("submit-set-points", "n_clicks_timestamp"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.SUBMIT_SET_POINTS, "n_clicks"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("meta-store", "data"), - State("input-hdd-set-point", "value"), - State("input-cdd-set-point", "value"), - State("submit-set-points", "n_clicks"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.INPUT_HDD_SET_POINT, "value"), + State(ElementIds.INPUT_CDD_SET_POINT, "value"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], + prevent_initial_call=False, ) -def degree_day_chart(ts, ts_click, df, meta, hdd_value, cdd_value, n_clicks, si_ip): - """Update the contents of tab two. Passing in the general info (df, meta).""" +def degree_day_chart( + ts, n_clicks, global_filter_data, df, meta, hdd_value, cdd_value, si_ip +): + """Redraw HDD/CDD chart only when Submit is clicked.""" - ctx = dash.callback_context - - if ( - ctx.triggered[0]["prop_id"] == "submit-set-points.n_clicks_timestamp" - or n_clicks is None - ): - hdd_setpoint = hdd_value - cdd_setpoint = cdd_value - - warning_setpoint = False - if cdd_setpoint < hdd_setpoint: - warning_setpoint = True - - color_hdd = "red" - color_cdd = "dodgerblue" - - hdd_array = [] - cdd_array = [] - months = df["month_names"].unique() - - for i in range(1, 13): - query_month = "month==" - - # calculates HDD per month - query = query_month + str(i) + " and DBT<=" + str(hdd_setpoint) - a = df.query(query)["DBT"].sub(hdd_setpoint) - hdd = a.sum(axis=0, skipna=True) - hdd = hdd / 24 - hdd = int(hdd) - hdd_array.append(hdd) - - # calculates CDD per month - query = query_month + str(i) + " and DBT>=" + str(cdd_setpoint) - a = df.query(query)["DBT"].sub(cdd_setpoint) - cdd = a.sum(axis=0, skipna=True) - cdd = cdd / 24 - cdd = int(cdd) - cdd_array.append(cdd) - - trace1 = go.Bar( - x=months, - y=hdd_array, - name="Heating Degree Days", - marker_color=color_hdd, - customdata=[abs(ele) for ele in hdd_array], - hovertemplate=( - " Heating Degree Days:
%{customdata} per month
" - ), + if df is None or meta is None: + raise PreventUpdate + + if isinstance(df, (list, tuple, dict)): + df = pd.DataFrame(df) + + # Apply global filter if active + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + df = apply_global_month_hour_filter( + df, global_filter_data, Variables.DBT.col_name ) - trace2 = go.Bar( - x=months, - y=cdd_array, - name="Cooling Degree Days", - marker_color=color_cdd, - customdata=cdd_array, - hovertemplate=( - "Cooling Degree Days:
%{customdata} per month
" - ), + + hdd_setpoint = hdd_value + cdd_setpoint = cdd_value + warning_setpoint = cdd_setpoint < hdd_setpoint + + color_hdd = "red" + color_cdd = "dodgerblue" + + # Check if there's a filter marker + has_filter_marker = "_is_filtered" in df.columns + filtered_mask = None + if has_filter_marker: + filtered_mask = df["_is_filtered"] + + # Get original DBT values if available + original_dbt_col = f"_{Variables.DBT.col_name}_original" + use_original_for_filtered = has_filter_marker and original_dbt_col in df.columns + + hdd_array, cdd_array = [], [] + hdd_array_filtered, cdd_array_filtered = [], [] + months = df[Variables.MONTH_NAMES.col_name].unique() + + for i in range(1, 13): + query_month = "month==" + month_query = query_month + str(i) + month_df = df.query(month_query) + + # Calculate HDD and CDD for unfiltered data + if has_filter_marker and filtered_mask is not None: + unfiltered_mask = ~month_df["_is_filtered"] + unfiltered_dbt = month_df[Variables.DBT.col_name][unfiltered_mask] + else: + unfiltered_dbt = month_df[Variables.DBT.col_name] + + # Calculate HDD for unfiltered data + a_unfiltered_hdd = unfiltered_dbt[unfiltered_dbt <= hdd_setpoint].sub( + hdd_setpoint ) + hdd_array.append(int(a_unfiltered_hdd.sum(skipna=True) / 24)) + + # Calculate CDD for unfiltered data + a_unfiltered_cdd = unfiltered_dbt[unfiltered_dbt >= cdd_setpoint].sub( + cdd_setpoint + ) + cdd_array.append(int(a_unfiltered_cdd.sum(skipna=True) / 24)) + + # Calculate HDD and CDD for filtered data (if any) + if ( + has_filter_marker + and filtered_mask is not None + and month_df["_is_filtered"].any() + ): + filtered_mask_month = month_df["_is_filtered"] + + if use_original_for_filtered: + # Use original DBT values for filtered data + month_indices = month_df[filtered_mask_month].index + filtered_dbt = df.loc[month_indices, original_dbt_col] + else: + # Fallback to current DBT values (shouldn't happen if filter is applied correctly) + filtered_dbt = month_df[Variables.DBT.col_name][filtered_mask_month] + + # Calculate HDD for filtered data + a_filtered_hdd = filtered_dbt[filtered_dbt <= hdd_setpoint].sub( + hdd_setpoint + ) + hdd_array_filtered.append(int(a_filtered_hdd.sum(skipna=True) / 24)) - data = [trace2, trace1] + # Calculate CDD for filtered data + a_filtered_cdd = filtered_dbt[filtered_dbt >= cdd_setpoint].sub( + cdd_setpoint + ) + cdd_array_filtered.append(int(a_filtered_cdd.sum(skipna=True) / 24)) + else: + hdd_array_filtered.append(0) + cdd_array_filtered.append(0) + + traces = [] - fig = go.Figure( - data=data, + # Add filtered data traces (gray) if any filtered data exists + if has_filter_marker and filtered_mask is not None and filtered_mask.any(): + trace_cdd_filtered = go.Bar( + x=months, + y=cdd_array_filtered, + name="Cooling Degree Days (Filtered)", + marker_color="gray", + customdata=cdd_array_filtered, + hovertemplate="Filtered Data
Cooling Degree Days:
%{customdata} per month
", ) - fig.update_layout( - barmode="relative", - margin=tight_margins, - template=template, - dragmode=False, - legend=dict( - orientation="h", yanchor="bottom", y=1.05, xanchor="right", x=1 - ), + traces.append(trace_cdd_filtered) + + trace_hdd_filtered = go.Bar( + x=months, + y=hdd_array_filtered, + name="Heating Degree Days (Filtered)", + marker_color="lightgray", + customdata=[abs(x) for x in hdd_array_filtered], + hovertemplate="Filtered Data
Heating Degree Days:
%{customdata} per month
", ) + traces.append(trace_hdd_filtered) + + # Add unfiltered data traces (normal colors) + trace2 = go.Bar( + x=months, + y=cdd_array, + name="Cooling Degree Days", + marker_color=color_cdd, + customdata=cdd_array, + hovertemplate="Cooling Degree Days:
%{customdata} per month
", + ) + traces.append(trace2) + + trace1 = go.Bar( + x=months, + y=hdd_array, + name="Heating Degree Days", + marker_color=color_hdd, + customdata=[abs(x) for x in hdd_array], + hovertemplate="Heating Degree Days:
%{customdata} per month
", + ) + traces.append(trace1) + + fig = go.Figure(data=traces) + fig.update_layout( + barmode="relative", + margin=tight_margins, + template=template, + dragmode=False, + legend=dict(orientation="h", yanchor="bottom", y=1.05, xanchor="right", x=1), + ) + fig.update_xaxes(showline=True, linewidth=1, linecolor="black", mirror=True) + fig.update_yaxes(showline=True, linewidth=1, linecolor="black", mirror=True) - fig.update_xaxes(showline=True, linewidth=1, linecolor="black", mirror=True) - fig.update_yaxes(showline=True, linewidth=1, linecolor="black", mirror=True) + custom_inputs = f"{hdd_value}-{cdd_value}" + units = generate_units_degree(si_ip) - custom_inputs = f"{hdd_value}-{cdd_value}" - units = generate_units_degree(si_ip) + chart = dcc.Graph( + id=ElementIds.DEGREE_DAYS_CHART, + config=generate_chart_name(TabNames.HDD_CDD, meta, custom_inputs, units), + figure=fig, + ) - chart = dcc.Graph( - id="degree-days-chart", - config=generate_chart_name("hdd_cdd", meta, custom_inputs, units), - figure=fig, + alert_children = ( + dmc.Alert( + "WARNING: Invalid Results! The CDD setpoint should be higher than the HDD setpoint!", + color="yellow", + variant="filled", + title="Warning", + withCloseButton=True, ) + if warning_setpoint + else None + ) - return chart, warning_setpoint + return chart, alert_children @callback( - Output("temp-profile-graph", "children"), + Output(ElementIds.TEMP_PROFILE_GRAPH, "children"), [ - Input("df-store", "modified_timestamp"), - Input("global-local-radio-input", "value"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) -def update_violin_tdb(ts, global_local, df, meta, si_ip): +def update_violin_tdb(ts, global_local, global_filter_data, df, meta, si_ip): + # Apply global filter if active + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + df = apply_global_month_hour_filter( + df, global_filter_data, Variables.DBT.col_name + ) units = generate_units_degree(si_ip) return dcc.Graph( - id="tdb-profile-graph", - className="violin-container", - config=generate_chart_name("DryBulbTemperature", meta, units), - figure=violin(df, "DBT", global_local, si_ip), + id=ElementIds.TDB_PROFILE_GRAPH, + config=generate_chart_name(TabNames.DRY_BULB_TEMPERATURE, meta, units), + figure=violin(df, Variables.DBT.col_name, global_local, si_ip), ) @callback( - Output("wind-speed-graph", "children"), + Output(ElementIds.WIND_SPEED_GRAPH, "children"), [ - Input("df-store", "modified_timestamp"), - Input("global-local-radio-input", "value"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) -def update_tab_wind(ts, global_local, df, meta, si_ip): +def update_tab_wind(ts, global_local, global_filter_data, df, meta, si_ip): """Update the contents of tab two. Passing in the general info (df, meta).""" + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + df = apply_global_month_hour_filter( + df, global_filter_data, Variables.WIND_SPEED.col_name + ) units = generate_units(si_ip) return dcc.Graph( - id="wind-profile-graph", - className="violin-container", - config=generate_chart_name("WindSpeed", meta, units), - figure=violin(df, "wind_speed", global_local, si_ip), + id=ElementIds.WIND_PROFILE_GRAPH, + config=generate_chart_name(TabNames.WIND_SPEED, meta, units), + figure=violin(df, Variables.WIND_SPEED.col_name, global_local, si_ip), ) @callback( - Output("humidity-profile-graph", "children"), + Output(ElementIds.HUMIDITY_PROFILE_GRAPH, "children"), [ - Input("df-store", "modified_timestamp"), - Input("global-local-radio-input", "value"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) -def update_tab_rh(ts, global_local, df, meta, si_ip): +def update_tab_rh(ts, global_local, global_filter_data, df, meta, si_ip): """Update the contents of tab two. Passing in the general info (df, meta).""" + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + df = apply_global_month_hour_filter( + df, global_filter_data, Variables.RH.col_name + ) units = generate_units(si_ip) return dcc.Graph( - id="rh-profile-graph", - className="violin-container", - config=generate_chart_name("RelativeHumidity", meta, units), - figure=violin(df, "RH", global_local, si_ip), + id=ElementIds.RH_PROFILE_GRAPH, + config=generate_chart_name(TabNames.RELATIVE_HUMIDITY, meta, units), + figure=violin(df, Variables.RH.col_name, global_local, si_ip), ) @callback( - Output("solar-radiation-graph", "children"), + Output(ElementIds.SOLAR_RADIATION_GRAPH, "children"), [ - Input("df-store", "modified_timestamp"), - Input("global-local-radio-input", "value"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) -def update_tab_gh_rad(ts, global_local, df, meta, si_ip): +def update_tab_gh_rad(ts, global_local, global_filter_data, df, meta, si_ip): """Update the contents of tab two. Passing in the general info (df, meta).""" + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + df = apply_global_month_hour_filter( + df, global_filter_data, Variables.GLOB_HOR_RAD.col_name + ) units = generate_units(si_ip) return dcc.Graph( - id="gh_rad-profile-graph", - className="violin-container", - config=generate_chart_name("GlobalHorizontalRadiation", meta, units), - figure=violin(df, "glob_hor_rad", global_local, si_ip), + id=ElementIds.GH_RAD_PROFILE_GRAPH, + config=generate_chart_name(TabNames.GLOBAL_HORIZONTAL_RADIATION, meta, units), + figure=violin(df, Variables.GLOB_HOR_RAD.col_name, global_local, si_ip), ) @callback( - Output("download-dataframe-csv", "data"), - [Input("download-button", "n_clicks")], + Output(ElementIds.DOWNLOAD_DATAFRAME_CSV, "data"), + [Input(ElementIds.DOWNLOAD_BUTTON, "n_clicks")], [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], prevent_initial_call=True, ) @@ -525,20 +602,22 @@ def download_clima_dataframe(n_clicks, df, meta, si_ip): elif df is not None: if si_ip == UnitSystem.SI: return dcc.send_data_frame( - df.to_csv, f"df_{meta['city']}_{meta['country']}_Clima_SIunit.csv" + df.to_csv, + f"df_{meta[Variables.CITY.col_name]}_{meta[Variables.COUNTRY.col_name]}_Clima_SIunit.csv", ) else: return dcc.send_data_frame( - df.to_csv, f"df_{meta['city']}_{meta['country']}_Clima_IPunit.csv" + df.to_csv, + f"df_{meta[Variables.CITY.col_name]}_{meta[Variables.COUNTRY.col_name]}_Clima_IPunit.csv", ) else: print("df not loaded yet") @callback( - Output("download-epw", "data"), - [Input("download-epw-button", "n_clicks")], - [State("meta-store", "data")], + Output(ElementIds.DOWNLOAD_EPW, "data"), + [Input(ElementIds.DOWN_EPW_BUTTON, "n_clicks")], + [State(ElementIds.SHARED_META_STORE, "data")], prevent_initial_call=True, ) def download_epw(n_clicks, meta): @@ -550,7 +629,7 @@ def download_epw(n_clicks, meta): lines[0] = lines[0].replace("b'", "") return dict( content="\n".join(lines), - filename=f"{meta['city']}_{meta['country']}.epw", + filename=f"{meta[Variables.CITY.col_name]}_{meta[Variables.COUNTRY.col_name]}.epw", ) else: raise PreventUpdate diff --git a/pages/sun.py b/pages/sun.py index a77c4c7f..3a2f7ed3 100644 --- a/pages/sun.py +++ b/pages/sun.py @@ -1,11 +1,15 @@ from copy import deepcopy +from pages.lib.global_element_ids import ElementIds import dash -import dash_bootstrap_components as dbc -import numpy as np -from dash import html, dcc +import dash_mantine_components as dmc + +from dash import dcc from dash_extensions.enrich import Output, Input, State, callback +from pages.lib.global_variables import Variables +from pages.lib.global_id_buttons import IdButtons +from pages.lib.global_tab_names import TabNames from config import PageUrls, DocLinks, PageInfo, UnitSystem from pages.lib.charts_sun import ( monthly_solar, @@ -17,7 +21,6 @@ sun_cloud_tab_explore_dropdown_names, dropdown_names, tight_margins, - month_lst, ) from pages.lib.template_graphs import heatmap, barchart, daily_profile from pages.lib.utils import ( @@ -49,59 +52,63 @@ sc_dropdown_names.pop("UTCI: no Sun & no Wind : categories", None) +def layout(): + """Contents of tab four.""" + return dmc.Stack( + p="md", + id=ElementIds.TAB_FOUR_CONTAINER, + children=[ + sun_path(), + dmc.Stack( + id=ElementIds.STATIC_SECTION, + w="100%", + ), + explore_daily_heatmap(), + ], + ) + + def sun_path(): """Return the layout for the custom sun path and its dropdowns.""" - return html.Div( - className="container-col justify-center", + return dmc.Stack( children=[ - html.Div( - children=title_with_link( - text="Sun path chart", - id_button="sun-path-chart-label", - doc_link=DocLinks.SUN_PATH_DIAGRAM, - ), + title_with_link( + text="Sun path chart", + id_button=IdButtons.SUN_PATH_CHART_LABEL, + doc_link=DocLinks.SUN_PATH_DIAGRAM, ), - dbc.Row( + dmc.Group( align="center", justify="center", children=[ - html.H6( - className="text-next-to-input", - children=["View: "], - style={"width": "10rem"}, - ), + dmc.Title("View: ", order=5), dropdown( - id="custom-sun-view-dropdown", + id=ElementIds.CUSTOM_SUN_VIEW_DROPDOWN, options={ "Spherical": "polar", "Cartesian": "cartesian", }, value="polar", - style={"width": "10rem"}, ), ], ), - dbc.Row( + dmc.Group( align="center", justify="center", children=[ - html.H6( - className="text-next-to-input", - children=["Select variable: "], - style={"width": "10rem"}, - ), + dmc.Title("Select Variable: ", order=5), dropdown( - id="custom-sun-var-dropdown", + id=ElementIds.CUSTOM_SUN_VAR_DROPDOWN, options=sc_dropdown_names, value="None", - style={"width": "20rem"}, ), ], ), - dcc.Loading( - type="circle", - children=html.Div( - id="custom-sunpath", + dmc.Skeleton( + visible=False, + h=450, + children=dmc.Center( + id=ElementIds.CUSTOM_SUNPATH, ), ), ], @@ -110,204 +117,288 @@ def sun_path(): def explore_daily_heatmap(): """Contents of the bottom part of the tab""" - return html.Div( - className="container-col full-width", + return dmc.Stack( + w="100%", children=[ - html.Div( - children=title_with_link( - text="Daily charts", - id_button="daily-chart-label", - doc_link=DocLinks.CUSTOM_HEATMAP, - ), + title_with_link( + text="Daily charts", + id_button=IdButtons.DAILY_CHART_LABEL, + doc_link=DocLinks.CUSTOM_HEATMAP, ), - html.Div( - className="container-row justify-center align-center mb-2", + dmc.Group( + align="center", + justify="center", children=[ - html.H6( - className="text-next-to-input", - children=["Select variable: "], - style={"width": "10rem"}, - ), + dmc.Title("Select variable: ", order=5), dropdown( - id="tab4-explore-dropdown", + id=ElementIds.SUN_EXPLORE_DROPDOWN, options=sun_cloud_tab_explore_dropdown_names, value="glob_hor_rad", - style={"width": "20rem"}, ), ], ), - dcc.Loading(type="circle", children=html.Div(id="tab4-daily")), - dcc.Loading( - type="circle", - children=html.Div(id="tab4-heatmap"), + dmc.Skeleton( + visible=False, + h=520, + children=dmc.Stack(id=ElementIds.SUN_DAILY), + ), + dmc.Skeleton( + visible=False, + h=520, + children=dmc.Stack(id=ElementIds.SUN_HEATMAP), ), ], ) -def static_section(): - return html.Div( - id="static-section", - className="container-col full-width", - children=[ - # ... - ], - ) - - -def layout(): - """Contents of tab four.""" - return html.Div( - className="container-col", - id="tab-four-container", - children=[sun_path(), static_section(), explore_daily_heatmap()], - ) - - -@callback(Output("static-section", "children"), [Input("si-ip-radio-input", "value")]) +@callback( + Output(ElementIds.STATIC_SECTION, "children"), + [Input(ElementIds.SHARED_SI_IP_RADIO_INPUT, "value")], +) def update_static_section(si_ip): hor_unit = "Wh/m²" if si_ip == UnitSystem.IP: hor_unit = "Btu/ft²" return [ - html.Div( - children=title_with_link( - text="Global and Diffuse Horizontal Solar Radiation (" + hor_unit + ")", - id_button="monthly-chart-label", - doc_link=DocLinks.SOLAR_RADIATION, - ), + title_with_link( + text="Global and Diffuse Horizontal Solar Radiation (" + hor_unit + ")", + id_button=IdButtons.MONTHLY_CHART_LABEL, + doc_link=DocLinks.SOLAR_RADIATION, ), - dcc.Loading( - type="circle", - children=html.Div(id="monthly-solar"), + dmc.Skeleton( + visible=False, + h=520, + children=dmc.Stack(id=ElementIds.MONTHLY_SOLAR), ), - html.Div( - children=title_with_link( - text="Cloud coverage", - id_button="cloud-chart-label", - doc_link=DocLinks.CLOUD_COVER, - ), + title_with_link( + text="Cloud coverage", + id_button=IdButtons.CLOUD_CHART_LABEL, + doc_link=DocLinks.CLOUD_COVER, ), - dcc.Loading( - type="circle", - children=html.Div(id="cloud-cover"), + dmc.Skeleton( + visible=False, + h=520, + children=dmc.Stack(id=ElementIds.CLOUD_COVER), ), ] @callback( [ - Output("monthly-solar", "children"), - Output("cloud-cover", "children"), + Output(ElementIds.MONTHLY_SOLAR, "children"), + Output(ElementIds.CLOUD_COVER, "children"), ], [ - Input("df-store", "modified_timestamp"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) -def monthly_and_cloud_chart(_, df, meta, si_ip): +def monthly_and_cloud_chart(_, global_filter_data, df, meta, si_ip): """Update the contents of tab four. Passing in the polar selection and the general info (df, meta).""" - # Sun Radiation - monthly = monthly_solar(df, si_ip) + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + df = apply_global_month_hour_filter( + df, + global_filter_data, + [ + Variables.GLOB_HOR_RAD.col_name, + Variables.DIF_HOR_RAD.col_name, + Variables.TOT_SKY_COVER.col_name, + ], + ) + # Don't filter out the filtered rows - keep them for gray display + # The monthly_solar and barchart functions will handle filtering + + # Sun Radiation - ensure all necessary columns are included + base_columns = [ + Variables.GLOB_HOR_RAD.col_name, + Variables.DIF_HOR_RAD.col_name, + Variables.MONTH.col_name, + Variables.HOUR.col_name, + Variables.MONTH_NAMES.col_name, + ] + if "_is_filtered" in df.columns: + base_columns.append("_is_filtered") + if f"_{Variables.GLOB_HOR_RAD.col_name}_original" in df.columns: + base_columns.append(f"_{Variables.GLOB_HOR_RAD.col_name}_original") + if f"_{Variables.DIF_HOR_RAD.col_name}_original" in df.columns: + base_columns.append(f"_{Variables.DIF_HOR_RAD.col_name}_original") + monthly = monthly_solar(df[base_columns], si_ip) monthly = monthly.update_layout(margin=tight_margins) - # Cloud Cover - cover = barchart(df, "tot_sky_cover", [False], [False, "", 3, 7], True, si_ip) + # Cloud Cover - remove filtered columns to disable gray filtering effect + cloud_base_columns = [ + Variables.TOT_SKY_COVER.col_name, + Variables.MONTH.col_name, + Variables.DOY.col_name, + ] + # Create a copy without filtered columns to disable gray filtering + cloud_df = df[cloud_base_columns].copy() + cover = barchart( + cloud_df, + Variables.TOT_SKY_COVER.col_name, + [False], + [False, "", 3, 7], + True, + si_ip, + ) cover = cover.update_layout( margin=tight_margins, title="", legend=dict(orientation="h", yanchor="bottom", y=1.05, xanchor="right", x=1), ) - cover.update_xaxes( - dict(tickmode="array", tickvals=np.arange(0, 12, 1), ticktext=month_lst) - ) + # Remove the hardcoded x-axis update - let barchart handle it dynamically units = generate_units(si_ip) return dcc.Graph( + style={"width": "100%", "height": "520px"}, config=generate_chart_name( - "Global_and_Diffuse_Horizontal_Solar_Radiation", meta, units + TabNames.GLOBAL_AND_DIFFUSE_HORIZONTAL_SOLAR_RADIATION, meta, units ), figure=monthly, ), dcc.Graph( - config=generate_chart_name("cloud_cover", meta, units), + style={"width": "100%", "height": "520px"}, + config=generate_chart_name(TabNames.CLOUD_COVER, meta, units), figure=cover, ) @callback( - Output("custom-sunpath", "children"), + Output(ElementIds.CUSTOM_SUNPATH, "children"), [ - Input("df-store", "modified_timestamp"), - Input("custom-sun-view-dropdown", "value"), - Input("custom-sun-var-dropdown", "value"), - Input("global-local-radio-input", "value"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.CUSTOM_SUN_VIEW_DROPDOWN, "value"), + Input(ElementIds.CUSTOM_SUN_VAR_DROPDOWN, "value"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) -def sun_path_chart(_, view, var, global_local, df, meta, si_ip): +def sun_path_chart(_, view, var, global_local, global_filter_data, df, meta, si_ip): """Update the contents of tab four. Passing in the polar selection and the general info (df, meta).""" + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + # For sun path chart, we need to filter all sun position related columns + target_cols = [ + Variables.GLOB_HOR_RAD.col_name, + Variables.DIF_HOR_RAD.col_name, + Variables.APPARENT_ELEVATION.col_name, + Variables.APPARENT_ZENITH.col_name, + Variables.AZIMUTH.col_name, + Variables.ELEVATION.col_name, + Variables.DAY.col_name, + Variables.MONTH_NAMES.col_name, + Variables.HOUR.col_name, + ] + # Add the selected variable if it's not "None" + if var != "None": + target_cols.append(var) + df = apply_global_month_hour_filter(df, global_filter_data, target_cols) + + # Ensure all necessary columns are included for filtered data display + base_columns = [ + Variables.APPARENT_ELEVATION.col_name, + Variables.APPARENT_ZENITH.col_name, + Variables.AZIMUTH.col_name, + Variables.ELEVATION.col_name, + Variables.DAY.col_name, + Variables.MONTH_NAMES.col_name, + Variables.HOUR.col_name, + ] + if "_is_filtered" in df.columns: + base_columns.append("_is_filtered") + if var != "None" and f"_{var}_original" in df.columns: + base_columns.append(f"_{var}_original") + if var != "None": + base_columns.append(var) + custom_inputs = "" if var == "None" else f"{var}" units = "" if var == "None" else generate_units(si_ip) if view == "polar": return dcc.Graph( - config=generate_chart_name("spherical_sunpath", meta, custom_inputs, units), - figure=polar_graph(df, meta, global_local, var, si_ip), + style={"maxWidth": "50em", "height": "520px"}, + config=generate_chart_name( + TabNames.SPHERICAL_SUNPATH, meta, custom_inputs, units + ), + figure=polar_graph(df[base_columns], meta, global_local, var, si_ip), ) else: return dcc.Graph( - config=generate_chart_name("cartesian_sunpath", meta, custom_inputs, units), - figure=custom_cartesian_solar(df, meta, global_local, var, si_ip), + style={"maxWidth": "50em", "height": "520px"}, + config=generate_chart_name( + TabNames.CARTESIAN_SUNPATH, meta, custom_inputs, units + ), + figure=custom_cartesian_solar( + df[base_columns], meta, global_local, var, si_ip + ), ) @callback( - Output("tab4-daily", "children"), + Output(ElementIds.SUN_DAILY, "children"), [ - Input("df-store", "modified_timestamp"), - Input("tab4-explore-dropdown", "value"), - Input("global-local-radio-input", "value"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.SUN_EXPLORE_DROPDOWN, "value"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) -def daily(_, var, global_local, df, meta, si_ip): +def daily(_, var, global_local, global_filter_data, df, meta, si_ip): """Update the contents of tab four section two. Passing in the general info (df, meta).""" + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + df = apply_global_month_hour_filter(df, global_filter_data, var) + custom_inputs = generate_custom_inputs(var) units = generate_units(si_ip) return dcc.Graph( - config=generate_chart_name("daily", meta, custom_inputs, units), + style={"width": "100%", "height": "520px"}, + config=generate_chart_name(TabNames.DAILY, meta, custom_inputs, units), figure=daily_profile(df, var, global_local, si_ip), ) @callback( - Output("tab4-heatmap", "children"), + Output(ElementIds.SUN_HEATMAP, "children"), [ - Input("df-store", "modified_timestamp"), - Input("tab4-explore-dropdown", "value"), - Input("global-local-radio-input", "value"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.SUN_EXPLORE_DROPDOWN, "value"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) -def update_heatmap(_, var, global_local, df, meta, si_ip): +def update_heatmap(_, var, global_local, global_filter_data, df, meta, si_ip): + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + df = apply_global_month_hour_filter(df, global_filter_data, var) + custom_inputs = generate_custom_inputs(var) units = generate_units(si_ip) return dcc.Graph( - config=generate_chart_name("heatmap", meta, custom_inputs, units), + style={"width": "100%", "height": "520px"}, + config=generate_chart_name(TabNames.HEATMAP, meta, custom_inputs, units), figure=heatmap(df, var, global_local, si_ip), ) diff --git a/pages/t_rh.py b/pages/t_rh.py index c3d90f82..d1620d27 100644 --- a/pages/t_rh.py +++ b/pages/t_rh.py @@ -1,9 +1,14 @@ import dash -from dash_extensions.enrich import Output, Input, State, dcc, html, callback +from dash_extensions.enrich import Output, Input, State, dcc, callback +import dash_mantine_components as dmc from config import PageUrls, DocLinks, PageInfo from pages.lib.global_scheme import dropdown_names from pages.lib.template_graphs import heatmap, yearly_profile, daily_profile +from pages.lib.global_variables import Variables +from pages.lib.global_element_ids import ElementIds +from pages.lib.global_id_buttons import IdButtons +from pages.lib.global_tab_names import TabNames from pages.lib.utils import ( generate_chart_name, generate_units, @@ -27,139 +32,215 @@ def layout(): - return html.Div( - className="container-col full-width", + return dmc.Stack( + p="md", children=[ - html.Div( - className="container-row full-width align-center justify-center", - children=[ - html.H4( - className="text-next-to-input", children=["Select a variable: "] - ), + dmc.Center( + [ + dmc.Title("Select a variable:", order=5, mr="md"), dropdown( - id="dropdown", - className="dropdown-t-rh", + id=ElementIds.ID_T_RH_DROPDOWN, options={var: dropdown_names[var] for var in var_to_plot}, value=dropdown_names[var_to_plot[0]], ), - ], - ), - html.Div( - className="container-col", - children=[ - html.Div( - children=title_with_link( - text="Yearly chart", - id_button="yearly-chart-label", - doc_link=DocLinks.TEMP_HUMIDITY_EXPLAINED, - ), - ), - dcc.Loading( - type="circle", - children=html.Div(id="yearly-chart"), - ), - html.Div( - children=title_with_link( - text="Daily chart", - id_button="daily-chart-label", - doc_link=DocLinks.TEMP_HUMIDITY_EXPLAINED, - ), - ), - dcc.Loading( - type="circle", - children=html.Div(id="daily"), - ), - html.Div( - children=title_with_link( - text="Heatmap chart", - id_button="heatmap-chart-label", - doc_link=DocLinks.TEMP_HUMIDITY_EXPLAINED, - ), - ), - dcc.Loading( - type="circle", - children=html.Div(id="heatmap"), - ), - html.Div( - children=title_with_tooltip( - text="Descriptive statistics", - tooltip_text="count, mean, std, min, max, and percentiles", - id_button="table-tmp-rh", - ), - ), - html.Div( - id="table-tmp-hum", - ), - ], + ] + ), + # Yearly Chart + title_with_link( + text="Yearly Chart", + id_button=IdButtons.YEARLY_CHART_LABEL, + doc_link=DocLinks.TEMP_HUMIDITY_EXPLAINED, + ), + dmc.Skeleton( + visible=False, + h=450, + children=dmc.Stack(id=ElementIds.YEARLY_CHART), + ), + # Daily chart + title_with_link( + text="Daily chart", + id_button=IdButtons.DAILY_CHART_LABEL, + doc_link=DocLinks.TEMP_HUMIDITY_EXPLAINED, + ), + dmc.Skeleton( + visible=False, + h=450, + children=dmc.Stack(id=ElementIds.DAILY), + ), + # Heatmap chart + title_with_link( + text="Heatmap chart", + id_button=IdButtons.HEATMAP_CHART_LABEL, + doc_link=DocLinks.TEMP_HUMIDITY_EXPLAINED, + ), + dmc.Skeleton( + visible=False, + h=450, + children=dmc.Stack(id=ElementIds.HEATMAP), + ), + # Descriptive statistics + title_with_tooltip( + text="Descriptive statistics", + tooltip_text="count, mean, std, min, max, and percentiles", + id_button=IdButtons.TABLE_TMP_RH, + ), + dmc.Skeleton( + visible=False, + h=450, + children=dmc.Stack(id=ElementIds.TABLE_TMP_HUM), ), ], ) @callback( - Output("yearly-chart", "children"), + Output(ElementIds.YEARLY_CHART, "children"), [ - Input("df-store", "modified_timestamp"), - Input("global-local-radio-input", "value"), - Input("dropdown", "value"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.ID_T_RH_DROPDOWN, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) -def update_yearly_chart(_, global_local, dd_value, df, meta, si_ip): +def update_yearly_chart(_, global_local, dd_value, global_filter_data, df, meta, si_ip): + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + target_columns = [ + Variables.DBT.col_name, + Variables.RH.col_name, + Variables.ADAPTIVE_CMF_80_LOW.col_name, + Variables.ADAPTIVE_CMF_80_UP.col_name, + Variables.ADAPTIVE_CMF_90_LOW.col_name, + Variables.ADAPTIVE_CMF_90_UP.col_name, + Variables.ADAPTIVE_CMF_RMT.col_name, + ] + df = apply_global_month_hour_filter(df, global_filter_data, target_columns) + if dd_value == dropdown_names[var_to_plot[0]]: - dbt_yearly = yearly_profile(df, "DBT", global_local, si_ip) + # Ensure all necessary columns are included for filtered data display + required_cols = [ + Variables.DBT.col_name, + Variables.UTC_TIME.col_name, + Variables.MONTH_NAMES.col_name, + Variables.DAY.col_name, + Variables.DOY.col_name, + Variables.ADAPTIVE_CMF_80_LOW.col_name, + Variables.ADAPTIVE_CMF_80_UP.col_name, + Variables.ADAPTIVE_CMF_90_LOW.col_name, + Variables.ADAPTIVE_CMF_90_UP.col_name, + Variables.ADAPTIVE_CMF_RMT.col_name, + ] + if "_is_filtered" in df.columns: + required_cols.append("_is_filtered") + if f"_{Variables.DBT.col_name}_original" in df.columns: + required_cols.append(f"_{Variables.DBT.col_name}_original") + dbt_yearly = yearly_profile( + df[required_cols], Variables.DBT.col_name, global_local, si_ip + ) dbt_yearly.update_layout(xaxis=dict(rangeslider=dict(visible=True))) units = generate_units_degree(si_ip) return dcc.Graph( - config=generate_chart_name("DryBulbTemperature_yearly", meta, units), + config=generate_chart_name( + TabNames.DRY_BULB_TEMPERATURE_YEARLY, meta, units + ), figure=dbt_yearly, ) else: - rh_yearly = yearly_profile(df, "RH", global_local, si_ip) + # Ensure all necessary columns are included for filtered data display + required_cols = [ + Variables.RH.col_name, + Variables.UTC_TIME.col_name, + Variables.MONTH_NAMES.col_name, + Variables.DAY.col_name, + ] + if "_is_filtered" in df.columns: + required_cols.append("_is_filtered") + if f"_{Variables.RH.col_name}_original" in df.columns: + required_cols.append(f"_{Variables.RH.col_name}_original") + rh_yearly = yearly_profile( + df[required_cols], Variables.RH.col_name, global_local, si_ip + ) rh_yearly.update_layout(xaxis=dict(rangeslider=dict(visible=True))) units = generate_units(si_ip) return dcc.Graph( - config=generate_chart_name("RelativeHumidity_yearly", meta, units), + config=generate_chart_name(TabNames.RELATIVE_HUMIDITY_YEARLY, meta, units), figure=rh_yearly, ) @callback( - Output("daily", "children"), + Output(ElementIds.DAILY, "children"), [ - Input("df-store", "modified_timestamp"), - Input("global-local-radio-input", "value"), - Input("dropdown", "value"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.ID_T_RH_DROPDOWN, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) -def update_daily(_, global_local, dd_value, df, meta, si_ip): +def update_daily(_, global_local, dd_value, global_filter_data, df, meta, si_ip): + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + target_columns = [Variables.DBT.col_name, Variables.RH.col_name] + df = apply_global_month_hour_filter(df, global_filter_data, target_columns) + if dd_value == dropdown_names[var_to_plot[0]]: + # Ensure all necessary columns are included for filtered data display + base_columns = [ + Variables.DBT.col_name, + Variables.HOUR.col_name, + Variables.UTC_TIME.col_name, + Variables.MONTH_NAMES.col_name, + Variables.DAY.col_name, + Variables.MONTH.col_name, + ] + if "_is_filtered" in df.columns: + base_columns.append("_is_filtered") + if f"_{Variables.DBT.col_name}_original" in df.columns: + base_columns.append(f"_{Variables.DBT.col_name}_original") units = generate_units_degree(si_ip) return dcc.Graph( - config=generate_chart_name("DryBulbTemperature_daily", meta, units), + config=generate_chart_name( + TabNames.DRY_BULB_TEMPERATURE_DAILY, meta, units + ), figure=daily_profile( - df[["DBT", "hour", "UTC_time", "month_names", "day", "month"]], - "DBT", + df[base_columns], + Variables.DBT.col_name, global_local, si_ip, ), ) else: + # Ensure all necessary columns are included for filtered data display + base_columns = [ + Variables.RH.col_name, + Variables.HOUR.col_name, + Variables.UTC_TIME.col_name, + Variables.MONTH_NAMES.col_name, + Variables.DAY.col_name, + Variables.MONTH.col_name, + ] + if "_is_filtered" in df.columns: + base_columns.append("_is_filtered") + if f"_{Variables.RH.col_name}_original" in df.columns: + base_columns.append(f"_{Variables.RH.col_name}_original") units = generate_units(si_ip) return dcc.Graph( - config=generate_chart_name("RelativeHumidity_daily", meta, units), + config=generate_chart_name(TabNames.RELATIVE_HUMIDITY_DAILY, meta, units), figure=daily_profile( - df[["RH", "hour", "UTC_time", "month_names", "day", "month"]], - "RH", + df[base_columns], + Variables.RH.col_name, global_local, si_ip, ), @@ -167,38 +248,60 @@ def update_daily(_, global_local, dd_value, df, meta, si_ip): @callback( - [Output("heatmap", "children")], + Output(ElementIds.HEATMAP, "children"), [ - Input("df-store", "modified_timestamp"), - Input("global-local-radio-input", "value"), - Input("dropdown", "value"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.ID_T_RH_DROPDOWN, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) -def update_heatmap(_, global_local, dd_value, df, meta, si_ip): - """Update the contents of tab three. Passing in general info (df, meta).""" +def update_heatmap(_, global_local, dd_value, global_filter_data, df, meta, si_ip): + """Update heatmap content.""" + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + target_columns = [Variables.DBT.col_name, Variables.RH.col_name] + df = apply_global_month_hour_filter(df, global_filter_data, target_columns) + + base_columns = [ + Variables.HOUR.col_name, + Variables.UTC_TIME.col_name, + Variables.MONTH_NAMES.col_name, + Variables.DAY.col_name, + ] + if "_is_filtered" in df.columns: + base_columns.append("_is_filtered") + if dd_value == dropdown_names[var_to_plot[0]]: + if f"_{Variables.DBT.col_name}_original" in df.columns: + base_columns.append(f"_{Variables.DBT.col_name}_original") units = generate_units_degree(si_ip) return dcc.Graph( - config=generate_chart_name("DryBulbTemperature_heatmap", meta, units), + config=generate_chart_name( + TabNames.DRY_BULB_TEMPERATURE_HEATMAP, meta, units + ), figure=heatmap( - df[["DBT", "hour", "UTC_time", "month_names", "day"]], - "DBT", + df[[Variables.DBT.col_name] + base_columns], + Variables.DBT.col_name, global_local, si_ip, ), ) else: + if f"_{Variables.RH.col_name}_original" in df.columns: + base_columns.append(f"_{Variables.RH.col_name}_original") units = generate_units(si_ip) return dcc.Graph( - config=generate_chart_name("RelativeHumidity_heatmap", meta, units), + config=generate_chart_name(TabNames.RELATIVE_HUMIDITY_HEATMAP, meta, units), figure=heatmap( - df[["RH", "hour", "UTC_time", "month_names", "day"]], - "RH", + df[[Variables.RH.col_name] + base_columns], + Variables.RH.col_name, global_local, si_ip, ), @@ -206,15 +309,37 @@ def update_heatmap(_, global_local, dd_value, df, meta, si_ip): @callback( - Output("table-tmp-hum", "children"), + Output(ElementIds.TABLE_TMP_HUM, "children"), + [ + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.ID_T_RH_DROPDOWN, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), + ], [ - Input("df-store", "modified_timestamp"), - Input("dropdown", "value"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], - [State("df-store", "data"), State("si-ip-unit-store", "data")], ) -def update_table(_, dd_value, df, si_ip): - """Update the contents of tab three. Passing in general info (df, meta).""" +def update_table(_, dd_value, global_filter_data, df, si_ip): + """Update the contents of descriptive statistics table.""" + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + target_columns = [Variables.DBT.col_name, Variables.RH.col_name] + df = apply_global_month_hour_filter(df, global_filter_data, target_columns) + # Filter out the filtered rows to avoid empty columns + if "_is_filtered" in df.columns: + df = df[~df["_is_filtered"]] + return summary_table_tmp_rh_tab( - df[["month", "hour", dd_value, "month_names"]], dd_value, si_ip + df[ + [ + Variables.MONTH.col_name, + Variables.HOUR.col_name, + dd_value, + Variables.MONTH_NAMES.col_name, + ] + ], + dd_value, + si_ip, ) diff --git a/pages/wind.py b/pages/wind.py index 0e48d6d3..285cb286 100644 --- a/pages/wind.py +++ b/pages/wind.py @@ -1,17 +1,19 @@ import dash -from dash import dcc, html +from dash import dcc +import dash_mantine_components as dmc from dash_extensions.enrich import Output, Input, State, callback +from pages.lib.global_element_ids import ElementIds from config import PageUrls, DocLinks, PageInfo -from pages.lib.global_scheme import month_lst, container_row_center_full +from pages.lib.global_scheme import month_lst from pages.lib.template_graphs import heatmap, wind_rose +from pages.lib.global_variables import Variables +from pages.lib.global_id_buttons import IdButtons +from pages.lib.global_tab_names import TabNames from pages.lib.utils import ( - title_with_tooltip, generate_chart_name, generate_units, - generate_custom_inputs_time, title_with_link, - dropdown, ) @@ -25,16 +27,14 @@ def sliders(): """Returns 2 sliders for the hour""" - return html.Div( - className="container-col justify-center", - id="slider-container", + return dmc.Stack( + id=ElementIds.SLIDER_CONTAINER, children=[ - html.Div( - className="container-row each-slider", + dmc.Group( children=[ - html.P("Month Range"), + dmc.Title("Month Range", order=5), dcc.RangeSlider( - id="month-slider", + id=ElementIds.MONTH_SLIDER, min=1, max=12, step=1, @@ -45,12 +45,11 @@ def sliders(): ), ], ), - html.Div( - className="container-row each-slider", + dmc.Group( children=[ - html.P("Hour Range"), + dmc.Title("Hour Range", order=5), dcc.RangeSlider( - id="hour-slider", + id=ElementIds.HOUR_SLIDER, min=1, max=24, step=1, @@ -67,81 +66,71 @@ def sliders(): def seasonal_wind_rose(): """Return the section with the 4 seasonal wind rose graphs.""" - return html.Div( - className="container-col", + return dmc.Stack( children=[ - html.Div( - children=title_with_link( - text="Seasonal Wind Rose", - id_button="seasonal-rose-chart", - doc_link=DocLinks.WIND_ROSE, - ), + title_with_link( + text="Seasonal Wind Rose", + id_button=IdButtons.SEASONAL_WIND_ROSE_DOC, + doc_link=DocLinks.WIND_ROSE, ), - html.Div( - className=container_row_center_full, + dmc.Grid( + gutter="md", children=[ - html.Div( - className="container-col", - children=[ - dcc.Loading( - type="circle", - children=html.Div( - id="winter-wind-rose", - className="daily-wind-graph", + dmc.GridCol( + span=6, + children=dmc.Stack( + children=[ + dcc.Loading( + type="circle", + children=dmc.Stack( + id=ElementIds.WINTER_WIND_ROSE, + ), ), - ), - html.P( - className="seasonal-text", id="winter-wind-rose-text" - ), - ], + dmc.Text(id=ElementIds.WINTER_WIND_ROSE_TEXT), + ], + ), ), - html.Div( - className="container-col", - children=[ - dcc.Loading( - type="circle", - children=html.Div( - id="spring-wind-rose", - className="daily-wind-graph", + dmc.GridCol( + span=6, + children=dmc.Stack( + children=[ + dcc.Loading( + type="circle", + children=dmc.Stack( + id=ElementIds.SPRING_WIND_ROSE, + ), ), - ), - html.P( - className="seasonal-text", id="spring-wind-rose-text" - ), - ], + dmc.Text(id=ElementIds.SPRING_WIND_ROSE_TEXT), + ], + ), ), - ], - ), - html.Div( - className=container_row_center_full, - children=[ - html.Div( - className="container-col", - children=[ - dcc.Loading( - type="circle", - children=html.Div( - id="summer-wind-rose", - className="daily-wind-graph", + dmc.GridCol( + span=6, + children=dmc.Stack( + children=[ + dcc.Loading( + type="circle", + children=dmc.Stack( + id=ElementIds.SUMMER_WIND_ROSE, + ), ), - ), - html.P( - className="seasonal-text", id="summer-wind-rose-text" - ), - ], + dmc.Text(id=ElementIds.SUMMER_WIND_ROSE_TEXT), + ], + ), ), - html.Div( - className="container-col", - children=[ - dcc.Loading( - type="circle", - children=html.Div( - id="fall-wind-rose", - className="daily-wind-graph", + dmc.GridCol( + span=6, + children=dmc.Stack( + children=[ + dcc.Loading( + type="circle", + children=dmc.Stack( + id=ElementIds.FALL_WIND_ROSE, + ), ), - ), - html.P(className="seasonal-text", id="fall-wind-rose-text"), - ], + dmc.Text(id=ElementIds.FALL_WIND_ROSE_TEXT), + ], + ), ), ], ), @@ -151,345 +140,209 @@ def seasonal_wind_rose(): def daily_wind_rose(): """Return the section for the 3 daily wind rose graphs.""" - return html.Div( - className="container-col full-width", - id="tab5-daily-container", + return dmc.Stack( + id=ElementIds.WIND_DAILY_CONTAINER, children=[ - html.Div( - children=title_with_link( - text="Daily Wind Rose", - id_button="daily-rose-chart", - doc_link=DocLinks.WIND_ROSE, - ), + title_with_link( + text="Daily Wind Rose", + id_button=IdButtons.DAILY_ROSE_CHART, + doc_link=DocLinks.WIND_ROSE, ), - html.Div( - id="daily-wind-rose-outer-container", - className="container-row full-width", + dmc.Grid( children=[ - html.Div( - className="container-col", - children=[ - html.Div( + dmc.GridCol( + span=4, + children=dmc.Stack( + children=[ dcc.Loading( type="circle", - children=html.Div( - className="daily-wind-graph", - id="morning-wind-rose", + children=dmc.Stack( + id=ElementIds.MORNING_WIND_ROSE, ), ), - ), - html.P(className="daily-text", id="morning-wind-rose-text"), - ], + dmc.Text(id=ElementIds.MORNING_WIND_ROSE_TEXT), + ], + ), ), - html.Div( - className="container-col", - children=[ - html.Div( + dmc.GridCol( + span=4, + children=dmc.Stack( + children=[ dcc.Loading( type="circle", - children=html.Div( - className="daily-wind-graph", - id="noon-wind-rose", + children=dmc.Stack( + id=ElementIds.NOON_WIND_ROSE, ), ), - ), - html.P(className="daily-text", id="noon-wind-rose-text"), - ], + dmc.Text(id=ElementIds.NOON_WIND_ROSE_TEXT), + ], + ), ), - html.Div( - className="container-col", - children=[ - html.Div( + dmc.GridCol( + span=4, + children=dmc.Stack( + children=[ dcc.Loading( type="circle", - children=html.Div( - className="daily-wind-graph", - id="night-wind-rose", + children=dmc.Stack( + id=ElementIds.NIGHT_WIND_ROSE, ), ), - ), - html.P(className="daily-text", id="night-wind-rose-text"), - ], - ), - ], - ), - ], - ) - - -def custom_wind_rose(): - return html.Div( - className="container-col justify-center full-width", - children=[ - html.Div( - children=title_with_tooltip( - text="Customizable Wind Rose", - tooltip_text=None, - id_button="custom-rose-chart", - ), - ), - html.Div( - className="container-row full-width justify-center", - id="tab5-custom-dropdown-container", - children=[ - html.Div( - className="container-col justify-center p-2 mr-2", - children=[ - html.Div( - className=container_row_center_full, - children=[ - html.H6( - style={"width": "8rem"}, - children=["Start Month:"], - ), - dropdown( - id="tab5-custom-start-month", - options={ - j: i + 1 for i, j in enumerate(month_lst) - }, - value=1, - style={"width": "6rem"}, - ), - ], - ), - html.Div( - className=container_row_center_full, - children=[ - html.H6( - style={"width": "8rem"}, - children=["Start Hour:"], - ), - dropdown( - id="tab5-custom-start-hour", - options={ - str(i) + ":00": i for i in range(0, 24) - }, - value=0, - style={"width": "6rem"}, - ), - ], - ), - ], - ), - html.Div( - className="container-col justify-center p-2 ml-2", - children=[ - html.Div( - className=container_row_center_full, - children=[ - html.H6( - style={"width": "8rem"}, - children=["End Month:"], - ), - dropdown( - id="tab5-custom-end-month", - options={ - j: i + 1 for i, j in enumerate(month_lst) - }, - value=12, - style={"width": "6rem"}, - ), - ], - ), - html.Div( - className=container_row_center_full, - children=[ - html.H6( - style={"width": "8rem"}, - children=["End Hour:"], - ), - dropdown( - id="tab5-custom-end-hour", - options={ - str(i) + ":00": i for i in range(1, 25) - }, - value=24, - style={"width": "6rem"}, - ), - ], - ), - ], + dmc.Text(id=ElementIds.NIGHT_WIND_ROSE_TEXT), + ], + ), ), ], ), - dcc.Loading( - type="circle", - children=html.Div(id="custom-wind-rose"), - ), ], ) def layout(): """Contents in the fifth tab 'Wind'.""" - return html.Div( - className="container-col justify-center", + return dmc.Stack( + p="md", children=[ - html.Div( - children=title_with_link( - text="Annual Wind Rose", - id_button="wind-rose-label", - doc_link=DocLinks.WIND_ROSE, - ), + title_with_link( + text="Annual Wind Rose", + id_button=IdButtons.WIND_ROSE_LABEL, + doc_link=DocLinks.WIND_ROSE, ), - dcc.Loading( - type="circle", - children=html.Div( - id="wind-rose", - ), + dmc.Skeleton( + visible=False, + h=450, + children=dmc.Stack(id=ElementIds.WIND_ROSE), ), - dcc.Loading( - type="circle", - children=html.Div(id="wind-speed"), + dmc.Skeleton( + visible=False, + h=450, + children=dmc.Stack(id=ElementIds.WIND_SPEED), ), - dcc.Loading( - type="circle", - children=html.Div(id="wind-direction"), + dmc.Skeleton( + visible=False, + h=450, + children=dmc.Stack(id=ElementIds.WIND_DIRECTION), ), seasonal_wind_rose(), daily_wind_rose(), - custom_wind_rose(), ], ) -# wind rose @callback( - Output("wind-rose", "children"), - Input("df-store", "modified_timestamp"), + Output(ElementIds.WIND_ROSE, "children"), [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), + ], + [ + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) -def update_annual_wind_rose(_, df, meta, si_ip): - """Update the contents of tab five. Passing in the info from the sliders and the general info (df, meta).""" +def update_annual_wind_rose(_, global_filter_data, df, meta, si_ip): + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import ( + apply_global_month_hour_filter, + ) + + df = apply_global_month_hour_filter( + df, + global_filter_data, + [Variables.WIND_SPEED.col_name, Variables.WIND_DIR.col_name], + ) + + months = [1, 12] + hours = [1, 24] + else: + months = [1, 12] + hours = [1, 24] + + skip_filter = global_filter_data and global_filter_data.get("filter_active", False) + annual = wind_rose(df, "", months, hours, True, si_ip, skip_time_filter=skip_filter) - annual = wind_rose(df, "", [1, 12], [1, 24], True, si_ip) units = generate_units(si_ip) return dcc.Graph( - config=generate_chart_name("annual_wind_rose", meta, units), + config=generate_chart_name(TabNames.ANNUAL_WIND_ROSE, meta, units), figure=annual, ) -# wind speed @callback( - Output("wind-speed", "children"), - # General + Output(ElementIds.WIND_SPEED, "children"), [ - Input("df-store", "modified_timestamp"), - Input("global-local-radio-input", "value"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) -def update_tab_wind_speed(_, global_local, df, meta, si_ip): - """Update the contents of tab five. Passing in the info from the sliders and the general info (df, meta).""" +def update_tab_wind_speed(_, global_local, global_filter_data, df, meta, si_ip): + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + + df = apply_global_month_hour_filter( + df, global_filter_data, Variables.WIND_SPEED.col_name + ) - speed = heatmap(df, "wind_speed", global_local, si_ip) + speed = heatmap(df, Variables.WIND_SPEED.col_name, global_local, si_ip) units = generate_units(si_ip) return dcc.Graph( - config=generate_chart_name("wind_speed", meta, units), + config=generate_chart_name(TabNames.WIND_SPEED, meta, units), figure=speed, ) -# wind direction @callback( - Output("wind-direction", "children"), - # General + Output(ElementIds.WIND_DIRECTION, "children"), [ - Input("global-local-radio-input", "value"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.SHARED_GLOBAL_LOCAL_RADIO_INPUT, "value"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), ], [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) -def update_tab_wind_direction(global_local, df, meta, si_ip): - """Update the contents of tab five. Passing in the info from the sliders and the general info (df, meta).""" - - direction = heatmap(df, "wind_dir", global_local, si_ip) - units = generate_units(si_ip) - return dcc.Graph( - config=generate_chart_name("wind_direction", meta, units), - figure=direction, - ) +def update_tab_wind_direction(_, global_local, global_filter_data, df, meta, si_ip): + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import apply_global_month_hour_filter + df = apply_global_month_hour_filter( + df, global_filter_data, Variables.WIND_DIR.col_name + ) -# Custom Wind rose -@callback( - Output("custom-wind-rose", "children"), - # Custom Graph Input - [ - Input("df-store", "modified_timestamp"), - Input("tab5-custom-start-month", "value"), - Input("tab5-custom-start-hour", "value"), - Input("tab5-custom-end-month", "value"), - Input("tab5-custom-end-hour", "value"), - ], - [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), - ], -) -def update_custom_wind_rose( - _, start_month, start_hour, end_month, end_hour, df, meta, si_ip -): - """Update the contents of tab five. Passing in the info from the sliders and the general info (df, meta).""" - - start_hour = int(start_hour) - end_hour = int(end_hour) - start_month = int(start_month) - end_month = int(end_month) - - # Wind Rose Graphs - if start_month <= end_month: - df = df.loc[(df["month"] >= start_month) & (df["month"] <= end_month)] - else: - df = df.loc[(df["month"] <= end_month) | (df["month"] >= start_month)] - if start_hour <= end_hour: - df = df.loc[(df["hour"] >= start_hour) & (df["hour"] <= end_hour)] - else: - df = df.loc[(df["hour"] <= end_hour) | (df["hour"] >= start_hour)] - custom = wind_rose( - df, "", [start_month, end_month], [start_hour, end_hour], True, si_ip - ) - custom_inputs = generate_custom_inputs_time( - start_month, end_month, start_hour, end_hour - ) + direction = heatmap(df, Variables.WIND_DIR.col_name, global_local, si_ip) units = generate_units(si_ip) return dcc.Graph( - config=generate_chart_name("custom_wind_rose", meta, custom_inputs, units), - figure=custom, + config=generate_chart_name(TabNames.WIND_DIRECTION, meta, units), + figure=direction, ) @callback( [ - Output("winter-wind-rose", "children"), - Output("spring-wind-rose", "children"), - Output("summer-wind-rose", "children"), - Output("fall-wind-rose", "children"), - Output("winter-wind-rose-text", "children"), - Output("spring-wind-rose-text", "children"), - Output("summer-wind-rose-text", "children"), - Output("fall-wind-rose-text", "children"), + Output(ElementIds.WINTER_WIND_ROSE, "children"), + Output(ElementIds.SPRING_WIND_ROSE, "children"), + Output(ElementIds.SUMMER_WIND_ROSE, "children"), + Output(ElementIds.FALL_WIND_ROSE, "children"), + Output(ElementIds.WINTER_WIND_ROSE_TEXT, "children"), + Output(ElementIds.SPRING_WIND_ROSE_TEXT, "children"), + Output(ElementIds.SUMMER_WIND_ROSE_TEXT, "children"), + Output(ElementIds.FALL_WIND_ROSE_TEXT, "children"), ], + [Input(ElementIds.SHARED_DF_STORE, "modified_timestamp")], [ - Input("df-store", "modified_timestamp"), - ], - [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) def update_seasonal_graphs(_, df, meta, si_ip): @@ -499,33 +352,38 @@ def update_seasonal_graphs(_, df, meta, si_ip): summer_months = [6, 8] fall_months = [9, 12] - # Wind Rose Graphs winter = wind_rose(df, "", winter_months, hours, False, si_ip) spring = wind_rose(df, "", spring_months, hours, True, si_ip) summer = wind_rose(df, "", summer_months, hours, False, si_ip) fall = wind_rose(df, "", fall_months, hours, False, si_ip) - # Text + query_calm_wind = f"{Variables.WIND_SPEED.col_name} == 0" + winter_df = df.loc[ - (df["month"] <= winter_months[1]) | (df["month"] >= winter_months[0]) + (df[Variables.MONTH.col_name] <= winter_months[1]) + | (df[Variables.MONTH.col_name] >= winter_months[0]) ] - query_calm_wind = "wind_speed == 0" winter_total_count = winter_df.shape[0] winter_calm_count = winter_df.query(query_calm_wind).shape[0] spring_df = df.loc[ - (df["month"] >= spring_months[0]) & (df["month"] <= spring_months[1]) + (df[Variables.MONTH.col_name] >= spring_months[0]) + & (df[Variables.MONTH.col_name] <= spring_months[1]) ] spring_total_count = spring_df.shape[0] spring_calm_count = spring_df.query(query_calm_wind).shape[0] summer_df = df.loc[ - (df["month"] >= summer_months[0]) & (df["month"] <= summer_months[1]) + (df[Variables.MONTH.col_name] >= summer_months[0]) + & (df[Variables.MONTH.col_name] <= summer_months[1]) ] summer_total_count = summer_df.shape[0] summer_calm_count = summer_df.query(query_calm_wind).shape[0] - fall_df = df.loc[(df["month"] >= fall_months[0]) & (df["month"] <= fall_months[1])] + fall_df = df.loc[ + (df[Variables.MONTH.col_name] >= fall_months[0]) + & (df[Variables.MONTH.col_name] <= fall_months[1]) + ] fall_total_count = fall_df.shape[0] fall_calm_count = fall_df.query(query_calm_wind).shape[0] @@ -534,8 +392,7 @@ def seasonal_chart_caption(month_start, month_end, count, n_calm): f"Observations between the months of {month_start} and {month_end} " f"between 01:00 hours and 24:00 hours. " f"Selected observations {str(count)} of 8760, or " - f"{str(int(100 * (count / 8760)))} %. {str(n_calm)} observations have " - f"calm winds." + f"{str(int(100 * (count / 8760)))} %. {str(n_calm)} observations have calm winds." ) winter_text = seasonal_chart_caption( @@ -562,22 +419,23 @@ def seasonal_chart_caption(month_start, month_end, count, n_calm): fall_total_count, fall_calm_count, ) + units = generate_units(si_ip) return ( dcc.Graph( - config=generate_chart_name("winter_wind_rose", meta, units), + config=generate_chart_name(TabNames.WINTER_WIND_ROSE, meta, units), figure=winter, ), dcc.Graph( - config=generate_chart_name("spring_wind_rose", meta, units), + config=generate_chart_name(TabNames.SPRING_WIND_ROSE, meta, units), figure=spring, ), dcc.Graph( - config=generate_chart_name("summer_wind_rose", meta, units), + config=generate_chart_name(TabNames.SUMMER_WIND_ROSE, meta, units), figure=summer, ), dcc.Graph( - config=generate_chart_name("fall_wind_rose", meta, units), + config=generate_chart_name(TabNames.FALL_WIND_ROSE, meta, units), figure=fall, ), winter_text, @@ -588,51 +446,68 @@ def seasonal_chart_caption(month_start, month_end, count, n_calm): @callback( - # Daily Graphs [ - Output("morning-wind-rose", "children"), - Output("noon-wind-rose", "children"), - Output("night-wind-rose", "children"), - Output("morning-wind-rose-text", "children"), - Output("noon-wind-rose-text", "children"), - Output("night-wind-rose-text", "children"), + Output(ElementIds.MORNING_WIND_ROSE, "children"), + Output(ElementIds.NOON_WIND_ROSE, "children"), + Output(ElementIds.NIGHT_WIND_ROSE, "children"), + Output(ElementIds.MORNING_WIND_ROSE_TEXT, "children"), + Output(ElementIds.NOON_WIND_ROSE_TEXT, "children"), + Output(ElementIds.NIGHT_WIND_ROSE_TEXT, "children"), ], - # General - Input("df-store", "modified_timestamp"), [ - State("df-store", "data"), - State("meta-store", "data"), - State("si-ip-unit-store", "data"), + Input(ElementIds.SHARED_DF_STORE, "modified_timestamp"), + Input(ElementIds.TOOLS_GLOBAL_FILTER_STORE, "data"), + ], + [ + State(ElementIds.SHARED_DF_STORE, "data"), + State(ElementIds.SHARED_META_STORE, "data"), + State(ElementIds.SHARED_SI_IP_UNIT_STORE, "data"), ], ) -def update_daily_graphs(_, df, meta, si_ip): - """Update the contents of tab five. Passing in the info from the sliders and the general info (df, meta).""" +def update_daily_graphs(_, global_filter_data, df, meta, si_ip): + if global_filter_data and global_filter_data.get("filter_active", False): + from pages.lib.layout import ( + apply_global_month_hour_filter, + ) + + df = apply_global_month_hour_filter( + df, + global_filter_data, + [Variables.WIND_SPEED.col_name, Variables.WIND_DIR.col_name], + ) + + months = [1, 12] + else: + months = [1, 12] - months = [1, 12] morning_times = [6, 13] noon_times = [14, 21] night_times = [22, 5] - # Wind Rose Graphs morning = wind_rose(df, "", months, morning_times, False, si_ip) noon = wind_rose(df, "", months, noon_times, False, si_ip) night = wind_rose(df, "", months, night_times, True, si_ip) - # Text - query_calm_wind = "wind_speed == 0" + query_calm_wind = f"{Variables.WIND_SPEED.col_name} == 0" + morning_df = df.loc[ - (df["hour"] >= morning_times[0]) & (df["hour"] <= morning_times[1]) + (df[Variables.HOUR.col_name] >= morning_times[0]) + & (df[Variables.HOUR.col_name] <= morning_times[1]) ] morning_total_count = morning_df.shape[0] morning_calm_count = morning_df.query(query_calm_wind).shape[0] noon_df = df.loc[ - (df["hour"] >= morning_times[0]) & (df["hour"] <= morning_times[1]) + (df[Variables.HOUR.col_name] >= noon_times[0]) + & (df[Variables.HOUR.col_name] <= noon_times[1]) ] noon_total_count = noon_df.shape[0] noon_calm_count = noon_df.query(query_calm_wind).shape[0] - night_df = df.loc[(df["hour"] <= night_times[1]) | (df["hour"] >= night_times[0])] + night_df = df.loc[ + (df[Variables.HOUR.col_name] <= night_times[1]) + | (df[Variables.HOUR.col_name] >= night_times[0]) + ] night_total_count = night_df.shape[0] night_calm_count = night_df.query(query_calm_wind).shape[0] @@ -641,33 +516,31 @@ def daily_chart_caption(hour_start, hour_end, count, calm_count): f"Observations between the months of Jan and Dec between " f"{str(hour_start)}:00 hours and {str(hour_end)}:00 hours. " f"Selected observations {count} of 8760, or " - f"{str(int(100 * (count / 8760)))}%. {calm_count} " - f"observations have calm winds." + f"{str(int(100 * (count / 8760)))}%. {calm_count} observations have calm winds." ) morning_text = daily_chart_caption( morning_times[0], morning_times[1], morning_total_count, morning_calm_count ) - noon_text = daily_chart_caption( noon_times[0], noon_times[1], noon_total_count, noon_calm_count ) - night_text = daily_chart_caption( night_times[0], night_times[1], night_total_count, night_calm_count ) + units = generate_units(si_ip) return ( dcc.Graph( - config=generate_chart_name("morning_wind_rose", meta, units), + config=generate_chart_name(TabNames.MORNING_WIND_ROSE, meta, units), figure=morning, ), dcc.Graph( - config=generate_chart_name("noon_wind_rose", meta, units), + config=generate_chart_name(TabNames.NOON_WIND_ROSE, meta, units), figure=noon, ), dcc.Graph( - config=generate_chart_name("night_wind_rose", meta, units), + config=generate_chart_name(TabNames.NIGHT_WIND_ROSE, meta, units), figure=night, ), morning_text, diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index d2d7baaf..00000000 --- a/requirements.txt +++ /dev/null @@ -1,59 +0,0 @@ -ansi2html==1.9.2 -black==25.1.0 -blinker==1.9.0 -bump2version==1.0.1 -cachelib==0.9.0 -certifi==2025.7.14 -charset-normalizer==3.4.2 -cleanpy==0.5.1 -click==8.2.1 -dash==2.15.0 -dash-bootstrap-components==1.2.0 -dash-core-components==2.0.0 -dash-extensions==1.0.7 -dash-html-components==2.0.0 -dash-iconify==0.1.2 -dash-mantine-components==0.12.1 -dash-table==5.0.0 -dataclass-wizard==0.22.3 -EditorConfig==0.17.1 -Flask==2.3.3 -Flask-Caching==2.0.2 -h5py==3.14.0 -idna==3.10 -importlib_metadata==8.7.0 -iniconfig==2.1.0 -itsdangerous==2.2.0 -Jinja2==3.1.6 -jsbeautifier==1.15.4 -llvmlite==0.44.0 -MarkupSafe==3.0.2 -more-itertools==9.1.0 -mypy_extensions==1.1.0 -narwhals==2.0.1 -nest-asyncio==1.6.0 -numba==0.61.2 -numpy==1.26.3 -packaging==25.0 -pandas==2.2.0 -pathspec==0.12.1 -platformdirs==4.3.8 -plotly==5.18.0 -pluggy==1.6.0 -pvlib==0.9.1 -Pygments==2.19.2 -pytest==8.4.1 -pythermalcomfort==2.9.1 -python-dateutil==2.9.0.post0 -pytz==2025.2 -requests==2.32.4 -retrying==1.4.1 -ruff==0.12.7 -scipy==1.12.0 -six==1.17.0 -tenacity==9.1.2 -typing_extensions==4.14.1 -tzdata==2025.2 -urllib3==2.5.0 -Werkzeug==3.0.6 -zipp==3.23.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..d17fb7bb --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.fixture(scope="session") +def base_url(pytestconfig): + return pytestconfig.getoption("base_url") diff --git a/tests/node/cypress.config.js b/tests/node/cypress.config.js deleted file mode 100644 index dae5f35d..00000000 --- a/tests/node/cypress.config.js +++ /dev/null @@ -1,12 +0,0 @@ -const { defineConfig } = require("cypress"); - -module.exports = defineConfig({ - defaultCommandTimeout: 30 * 1000, - video: true, - videoCompression: true, - e2e: { - setupNodeEvents(on, config) { - // implement node event listeners here - } - }, -}); diff --git a/tests/node/cypress/.gitignore b/tests/node/cypress/.gitignore deleted file mode 100644 index 2ca81ab1..00000000 --- a/tests/node/cypress/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -screenshots -videos \ No newline at end of file diff --git a/tests/node/cypress/e2e/spec.cy.js b/tests/node/cypress/e2e/spec.cy.js deleted file mode 100644 index dcc84470..00000000 --- a/tests/node/cypress/e2e/spec.cy.js +++ /dev/null @@ -1,110 +0,0 @@ -function click_tab(name) { - cy.get('.nav-item') - .contains(name) - .click(); -} - -function load_epw() { - cy.get('input[type=file]').selectFile('test.epw', {force: true}); -} - -describe('Clima', () => { - it('loads all tabs for uploaded EPW', () => { - cy.visit('http://127.0.0.1:8080'); - cy.contains('CBE Clima Tool'); - cy.contains('Current Location: N/A'); - - // Upload - load_epw() - cy.contains('The EPW was successfully loaded!'); - cy.contains('Current Location: Bologna Marconi AP, ITA'); - - // Climate Summary - click_tab('Climate Summary'); - cy.contains('data collected between 2004 and 2018'); - cy.contains('Longitude: 11.2969'); - cy.contains('Latitude: 44.5308'); - cy.contains('Elevation above sea level: 37.0 m'); - cy.contains('This file is based on data collected between 2004 and 2018'); - cy.contains('Köppen–Geiger climate zone: Cfa. Humid subtropical, no dry season.'); - cy.contains('Average yearly temperature: 14.5 °C'); - cy.contains('Hottest yearly temperature (99%): 34.0 °C'); - cy.contains('Coldest yearly temperature (1%): -2.0 °C'); - cy.contains('Annual cumulative horizontal solar radiation: 1546.12 kWh/m2'); - cy.contains('Percentage of diffuse horizontal solar radiation: 39.4 %'); - - // Temperature and Humidity - click_tab('Temperature and Humidity'); - cy.contains('Yearly chart'); - cy.contains('Dry bulb temperature (°C)'); - // TODO: simulate mouseover - cy.contains('Daily chart'); - // TODO: simulate mouseover - cy.contains('Heatmap chart'); - // TODO: simulate mouseover - cy.contains('Descriptive statistics'); - cy.contains('12.1'); // January max - - // Sun and Clouds - click_tab('Sun and Clouds'); - cy.contains('Sun path chart'); - // TODO - cy.contains('Global and Diffuse Horizontal Solar Radiation (Wh/m²)'); - // TODO - cy.contains('Cloud coverage'); - // TODO - cy.contains('Daily charts'); - // TODO - - // Wind - click_tab('Wind'); - cy.contains('Annual Wind Rose'); - // TODO - cy.contains('Seasonal Wind Rose'); - cy.contains('Observations between the months of Dec and Feb between 01:00 hours and 24:00 hours.'); - cy.contains('Selected observations 2160 of 8760, or 24 %.'); - cy.contains('40 observations have calm winds.'); - // TODO - cy.contains('Daily Wind Rose'); - // TODO - cy.contains('Customizable Wind Rose'); - // TODO - - // Psychrometric Chart - click_tab('Psychrometric Chart'); - // TODO - // cy.contains('Humidity Ratio g water/kg dry air'); - - // Natural Ventilation - click_tab('Natural Ventilation'); - // TODO - cy.contains('Outdoor dry-bulb air temperature range'); - cy.contains('Hours when the Dry bulb temperature is in the range 10 to 24 °C'); - cy.contains('Percentage of hours the Dry bulb temperature is in the range 10 to 24 °C'); - - // Outdoor Comfort - click_tab('Outdoor Comfort'); - // TODO - cy.contains('The Best Weather Condition is: utci_noSun_noWind_categories'); - // TODO - cy.contains('UTCI thermal stress chart'); - cy.contains('no thermal stress'); - cy.contains('UTCI thermal stress distribution'); - - // Data Explorer - click_tab('Data Explorer'); - // TODO - }); - - it('responds to banner radio buttons', () => { - cy.visit('http://127.0.0.1:8080'); - load_epw() - cy.contains('The EPW was successfully loaded!'); - click_tab('Temperature and Humidity') - cy.contains('Global Value Ranges').click(); - cy.contains('-40'); // Global minimum: not something you see in Italy! - cy.contains('IP').click(); - cy.contains('100'); // Not a Celsius temperature! - cy.contains('Dry bulb temperature (°F)'); - }); -}) \ No newline at end of file diff --git a/tests/node/cypress/fixtures/example.json b/tests/node/cypress/fixtures/example.json deleted file mode 100644 index 02e42543..00000000 --- a/tests/node/cypress/fixtures/example.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "Using fixtures to represent data", - "email": "hello@cypress.io", - "body": "Fixtures are a great way to mock data for responses to routes" -} diff --git a/tests/node/cypress/support/commands.js b/tests/node/cypress/support/commands.js deleted file mode 100644 index 66ea16ef..00000000 --- a/tests/node/cypress/support/commands.js +++ /dev/null @@ -1,25 +0,0 @@ -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add('login', (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) \ No newline at end of file diff --git a/tests/node/cypress/support/e2e.js b/tests/node/cypress/support/e2e.js deleted file mode 100644 index 0e7290a1..00000000 --- a/tests/node/cypress/support/e2e.js +++ /dev/null @@ -1,20 +0,0 @@ -// *********************************************************** -// This example support/e2e.js is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: -import './commands' - -// Alternatively you can use CommonJS syntax: -// require('./commands') \ No newline at end of file diff --git a/tests/node/package-lock.json b/tests/node/package-lock.json deleted file mode 100644 index 37f5fdc1..00000000 --- a/tests/node/package-lock.json +++ /dev/null @@ -1,2207 +0,0 @@ -{ - "name": "clima", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "clima", - "version": "0.0.0", - "devDependencies": { - "cypress": "^13.8.1" - } - }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/@cypress/request": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.9.tgz", - "integrity": "sha512-I3l7FdGRXluAS44/0NguwWlO83J18p0vlr2FYHrJkWdNYhgVoiYo61IXPqaOsL+vNxU1ZqMACzItGK3/KKDsdw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~4.0.4", - "http-signature": "~1.4.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "performance-now": "^2.1.0", - "qs": "6.14.0", - "safe-buffer": "^5.1.2", - "tough-cookie": "^5.0.0", - "tunnel-agent": "^0.6.0", - "uuid": "^8.3.2" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@cypress/xvfb": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", - "integrity": "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==", - "dev": true, - "dependencies": { - "debug": "^3.1.0", - "lodash.once": "^4.1.1" - } - }, - "node_modules/@cypress/xvfb/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/@types/node": { - "version": "18.18.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.18.10.tgz", - "integrity": "sha512-luANqZxPmjTll8bduz4ACs/lNTCLuWssCyjqTY9yLdsv1xnViQp3ISKwsEWOIecO13JWUqjVdig/Vjjc09o8uA==", - "dev": true, - "optional": true, - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@types/sinonjs__fake-timers": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", - "integrity": "sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==", - "dev": true - }, - "node_modules/@types/sizzle": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.6.tgz", - "integrity": "sha512-m04Om5Gz6kbjUwAQ7XJJQ30OdEFsSmAVsvn4NYwcTRyMVpKKa1aPuESw1n2CxS5fYkOQv3nHgDKeNa8e76fUkw==", - "dev": true - }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/arch": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", - "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/asn1": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", - "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": "~2.1.0" - } - }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/async": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", - "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==", - "dev": true - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", - "dev": true, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/aws4": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", - "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==", - "dev": true - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tweetnacl": "^0.14.3" - } - }, - "node_modules/blob-util": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz", - "integrity": "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==", - "dev": true - }, - "node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "dev": true - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/cachedir": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", - "integrity": "sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", - "dev": true - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/check-more-types": { - "version": "2.24.0", - "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", - "integrity": "sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "engines": { - "node": ">=8" - } - }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-table3": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.3.tgz", - "integrity": "sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==", - "dev": true, - "dependencies": { - "string-width": "^4.2.0" - }, - "engines": { - "node": "10.* || >= 12.*" - }, - "optionalDependencies": { - "@colors/colors": "1.5.0" - } - }, - "node_modules/cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", - "dev": true, - "dependencies": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", - "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/common-tags": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", - "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", - "dev": true, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cypress": { - "version": "13.8.1", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.8.1.tgz", - "integrity": "sha512-Uk6ovhRbTg6FmXjeZW/TkbRM07KPtvM5gah1BIMp4Y2s+i/NMxgaLw0+PbYTOdw1+egE0FP3mWRiGcRkjjmhzA==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "@cypress/request": "^3.0.0", - "@cypress/xvfb": "^1.2.4", - "@types/sinonjs__fake-timers": "8.1.1", - "@types/sizzle": "^2.3.2", - "arch": "^2.2.0", - "blob-util": "^2.0.2", - "bluebird": "^3.7.2", - "buffer": "^5.7.1", - "cachedir": "^2.3.0", - "chalk": "^4.1.0", - "check-more-types": "^2.24.0", - "cli-cursor": "^3.1.0", - "cli-table3": "~0.6.1", - "commander": "^6.2.1", - "common-tags": "^1.8.0", - "dayjs": "^1.10.4", - "debug": "^4.3.4", - "enquirer": "^2.3.6", - "eventemitter2": "6.4.7", - "execa": "4.1.0", - "executable": "^4.1.1", - "extract-zip": "2.0.1", - "figures": "^3.2.0", - "fs-extra": "^9.1.0", - "getos": "^3.2.1", - "is-ci": "^3.0.1", - "is-installed-globally": "~0.4.0", - "lazy-ass": "^1.6.0", - "listr2": "^3.8.3", - "lodash": "^4.17.21", - "log-symbols": "^4.0.0", - "minimist": "^1.2.8", - "ospath": "^1.2.2", - "pretty-bytes": "^5.6.0", - "process": "^0.11.10", - "proxy-from-env": "1.0.0", - "request-progress": "^3.0.0", - "semver": "^7.5.3", - "supports-color": "^8.1.1", - "tmp": "~0.2.1", - "untildify": "^4.0.0", - "yauzl": "^2.10.0" - }, - "bin": { - "cypress": "bin/cypress" - }, - "engines": { - "node": "^16.0.0 || ^18.0.0 || >=20.0.0" - } - }, - "node_modules/dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", - "dev": true, - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/dayjs": { - "version": "1.11.10", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", - "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==", - "dev": true - }, - "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/debug/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dev": true, - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/enquirer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", - "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", - "dev": true, - "dependencies": { - "ansi-colors": "^4.1.1", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/eventemitter2": { - "version": "6.4.7", - "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", - "integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==", - "dev": true - }, - "node_modules/execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/executable": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz", - "integrity": "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==", - "dev": true, - "dependencies": { - "pify": "^2.2.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true - }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], - "license": "MIT" - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "dependencies": { - "pend": "~1.2.0" - } - }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/fs-extra/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/getos": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz", - "integrity": "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==", - "dev": true, - "dependencies": { - "async": "^3.2.0" - } - }, - "node_modules/getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/global-dirs": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", - "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", - "dev": true, - "dependencies": { - "ini": "2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-signature": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", - "integrity": "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0", - "jsprim": "^2.0.2", - "sshpk": "^1.18.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "dev": true, - "engines": { - "node": ">=8.12.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/ini": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", - "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/is-ci": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", - "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", - "dev": true, - "dependencies": { - "ci-info": "^3.2.0" - }, - "bin": { - "is-ci": "bin.js" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-installed-globally": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", - "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", - "dev": true, - "dependencies": { - "global-dirs": "^3.0.0", - "is-path-inside": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "dev": true - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", - "dev": true - }, - "node_modules/jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "dev": true, - "license": "(AFL-2.1 OR BSD-3-Clause)" - }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true - }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/jsonfile/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/jsprim": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", - "integrity": "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], - "license": "MIT", - "dependencies": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.4.0", - "verror": "1.10.0" - } - }, - "node_modules/lazy-ass": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", - "integrity": "sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==", - "dev": true, - "engines": { - "node": "> 0.8" - } - }, - "node_modules/listr2": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz", - "integrity": "sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==", - "dev": true, - "dependencies": { - "cli-truncate": "^2.1.0", - "colorette": "^2.0.16", - "log-update": "^4.0.0", - "p-map": "^4.0.0", - "rfdc": "^1.3.0", - "rxjs": "^7.5.1", - "through": "^2.3.8", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "enquirer": ">= 2.3.0 < 3" - }, - "peerDependenciesMeta": { - "enquirer": { - "optional": true - } - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "dev": true - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", - "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", - "dev": true, - "dependencies": { - "ansi-escapes": "^4.3.0", - "cli-cursor": "^3.1.0", - "slice-ansi": "^4.0.0", - "wrap-ansi": "^6.2.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ospath": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz", - "integrity": "sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==", - "dev": true - }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true - }, - "node_modules/performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "dev": true - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pretty-bytes": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", - "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", - "dev": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "dev": true, - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/proxy-from-env": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", - "integrity": "sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==", - "dev": true - }, - "node_modules/pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dev": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/request-progress": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", - "integrity": "sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==", - "dev": true, - "dependencies": { - "throttleit": "^1.0.0" - } - }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/rfdc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", - "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==", - "dev": true - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, - "node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/sshpk": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", - "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - }, - "bin": { - "sshpk-conv": "bin/sshpk-conv", - "sshpk-sign": "bin/sshpk-sign", - "sshpk-verify": "bin/sshpk-verify" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/throttleit": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.1.tgz", - "integrity": "sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true - }, - "node_modules/tldts": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", - "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^6.1.86" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", - "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tmp": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", - "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", - "dev": true, - "dependencies": { - "rimraf": "^3.0.0" - }, - "engines": { - "node": ">=8.17.0" - } - }, - "node_modules/tough-cookie": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", - "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^6.1.32" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", - "dev": true, - "license": "Unlicense" - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true, - "optional": true - }, - "node_modules/untildify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", - "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - } - } -} diff --git a/tests/node/package.json b/tests/node/package.json deleted file mode 100644 index cb40d108..00000000 --- a/tests/node/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "clima", - "version": "0.0.0", - "description": "Tests for Clima", - "scripts": { - "cy:run": "cypress run", - "cy:open": "cypress open" - }, - "devDependencies": { - "cypress": "^13.8.1" - } -} diff --git a/tests/node/test.epw b/tests/node/test.epw deleted file mode 120000 index d4d1daed..00000000 --- a/tests/node/test.epw +++ /dev/null @@ -1 +0,0 @@ -../python/ITA_ER_Bologna-Marconi.AP.161400_TMYx.2004-2018.epw \ No newline at end of file diff --git a/tests/python/test_utils.py b/tests/python/test_utils.py deleted file mode 100644 index ab085c42..00000000 --- a/tests/python/test_utils.py +++ /dev/null @@ -1,40 +0,0 @@ -import os - -import pandas as pd - -from config import UnitSystem - -import requests - -from pages.lib.utils import summary_table_tmp_rh_tab -from pages.lib.extract_df import get_data, create_df - - -def save_epw_test(path_file): - test_url = "http://climate.onebuilding.org/WMO_Region_6_Europe/ITA_Italy/ER_Emilia-Romagna/ITA_ER_Bologna-Marconi.AP.161400_TMYx.2004-2018.zip" - - lines = get_data(source_url=test_url) - df, _ = create_df(lst=lines, file_name=test_url) - - df.to_pickle(path_file, compression="gzip") - - -def import_epw_test(): - epw_test_file_path = "epw_test.pkl" - - if not os.path.isfile(epw_test_file_path): - save_epw_test(path_file=epw_test_file_path) - - return pd.read_pickle(epw_test_file_path, compression="gzip") - - -def test_summary_table_tmp_rh_tab(): - try: - # check tha the climate.onebuilding website is on - print(requests.get("https://climate.onebuilding.org", timeout=2)) - df = import_epw_test() - data_table = summary_table_tmp_rh_tab(df, "RH", UnitSystem.SI) - - assert data_table.data[0]["month"] == "Jan" - except requests.exceptions.ConnectionError: - pass diff --git a/tests/python/ITA_ER_Bologna-Marconi.AP.161400_TMYx.2004-2018.epw b/tests/test.epw similarity index 100% rename from tests/python/ITA_ER_Bologna-Marconi.AP.161400_TMYx.2004-2018.epw rename to tests/test.epw diff --git a/tests/test_explorer.py b/tests/test_explorer.py new file mode 100644 index 00000000..b94b45d5 --- /dev/null +++ b/tests/test_explorer.py @@ -0,0 +1,62 @@ +import pytest +from playwright.sync_api import Page, expect +from utils import upload_epw_file, open_tab + + +@pytest.fixture(scope="function", autouse=True) +def setup(page: Page, base_url): + """Setup: Go to base URL, upload EPW file, and open Explorer page""" + page.goto(base_url) + upload_epw_file(page) + open_tab(page, "Data Explorer") + yield + + +# -------------------- Test Explorer Core Elements -------------------- +def test_explorer_core_elements(page: Page): + """Check key titles and section one & two charts""" + # Titles + for text in [ + "Select a variable:", + "Yearly chart", + "Daily chart", + "Heatmap chart", + "Descriptive statistics", + "Customizable heatmap", + "More charts", + ]: + expect(page.get_by_text(text)).to_be_visible() + # Charts + for chart in [ + "#yearly-explore", + "#query-daily", + "#query-heatmap", + "#table-data-explorer", + "#custom-heatmap", + ]: + expect(page.locator(chart)).to_be_visible() + + +# -------------------- Test Section Three -------------------- +def test_explorer_section_three(page: Page): + """Section 3 dropdowns and chart visibility""" + for selector in [ + "#explorer-sec3-var-x-dropdown", + "#explorer-sec3-var-y-dropdown", + "#explorer-sec3-colorby-dropdown", + "#three-var", + "#two-var", + ]: + expect(page.locator(selector)).to_be_visible() + + +# -------------------- Test Dropdown Interaction -------------------- +def test_explorer_dropdown_interaction(page: Page): + """Switch variable in dropdown and verify chart reloads""" + dropdown = page.locator("#sec1-var-dropdown") + dropdown.click() + options = page.locator(".VirtualizedSelectOption") + assert options.count() > 0 + options.filter(has_text="Relative humidity").first.click() + expect(page.locator("#yearly-explore")).to_be_visible() + expect(page.locator("#query-heatmap")).to_be_visible() diff --git a/tests/test_filter.py b/tests/test_filter.py new file mode 100644 index 00000000..9e377b46 --- /dev/null +++ b/tests/test_filter.py @@ -0,0 +1,219 @@ +import re +from typing import List, Tuple, Optional + +import pytest +from playwright.sync_api import Page, expect, Locator + +from utils import upload_epw_file + + +def ensure_local_mode_and_invert_off(page: Page): + """Ensure 'Local' mode is active and 'Invert' is off.""" + try: + page.get_by_text("Local", exact=True).click() + except Exception: + pass + try: + invert = ( + page.get_by_text("Invert", exact=True) + .locator("..") + .locator("input[type=checkbox]") + ) + if invert.is_checked(): + invert.uncheck() + except Exception: + pass + + +def open_tools_menu_and_filter_section(page: Page): + apply_btn = page.get_by_text("Apply month and hour filter", exact=False) + expect(apply_btn.first).to_be_visible() + ensure_local_mode_and_invert_off(page) + + +BASELINE_MONTH: Tuple[int, int] = (1, 12) +BASELINE_HOUR: Tuple[int, int] = (0, 24) +NARROW_MONTHS: List[Tuple[int, int]] = [(1, 4), (8, 12)] +NARROW_HOURS: List[Tuple[int, int]] = [(0, 3), (18, 24)] + + +def _sliders_in_group(page: Page, group_label: str) -> List[Locator]: + """Return the two sliders for a given group using global order: [month_lo, month_hi, hour_lo, hour_hi].""" + sliders = page.get_by_role("slider") + n = sliders.count() + if n >= 4: + if "month" in group_label.lower(): + return [sliders.nth(0), sliders.nth(1)] + if "hour" in group_label.lower(): + return [sliders.nth(2), sliders.nth(3)] + return [sliders.nth(0), sliders.nth(1)] if n >= 2 else [] + + +def _read_slider_value(slider: Locator) -> Optional[float]: + """Read a slider's numeric value.""" + for attr in ("aria-valuenow", "value"): + v = slider.get_attribute(attr) + if v: + try: + return float(v) + except ValueError: + continue + return None + + +def _keyboard_move_to( + slider: Locator, target: float, vmin: float, vmax: float, step: float = 1.0 +): + """Move a slider thumb to target using only keyboard arrows.""" + slider.scroll_into_view_if_needed() + slider.focus() + current = _read_slider_value(slider) + if current is None: + slider.press("Home") + current = vmin + diff = target - current + key = "ArrowRight" if diff > 0 else "ArrowLeft" + steps = int(abs(diff) / max(step, 1)) + steps = min(steps, 200) + for _ in range(steps): + slider.press(key) + + +def _set_range( + page: Page, + group_label: str, + target_lo: float, + target_hi: float, + domain_lo: float, + domain_hi: float, + step: float, +): + sliders = _sliders_in_group(page, group_label) + if len(sliders) < 2: + return + lo, hi = sliders[0], sliders[1] + _keyboard_move_to(lo, target_lo, domain_lo, domain_hi, step) + _keyboard_move_to(hi, target_hi, domain_lo, domain_hi, step) + + +def set_month_range(page: Page, m_start: int, m_end: int): + _set_range(page, "Month Range", m_start, m_end, 1, 12, step=1.0) + + +def set_hour_range(page: Page, h_start: int, h_end: int): + _set_range(page, "Hour Range", h_start, h_end, 0, 24, step=1.0) + + +def _click_apply(page: Page): + """Click the 'Apply' button.""" + try: + page.get_by_role( + "button", name=re.compile("Apply month and hour filter", re.I) + ).first.click() + except Exception: + page.get_by_text("Apply month and hour filter", exact=False).first.click() + + +def apply_filter(page: Page, month_range: Tuple[int, int], hour_range: Tuple[int, int]): + """Apply the selected filter settings.""" + set_month_range(page, month_range[0], month_range[1]) + set_hour_range(page, hour_range[0], hour_range[1]) + _click_apply(page) + + +def _chart_state_hash(page: Page, chart_selector: str) -> str: + """Generate a simple hash from chart inner HTML.""" + node = page.locator(chart_selector).first + if not node.is_visible(): + node.scroll_into_view_if_needed() + html = node.inner_html() + return str(hash(html)) + + +def _wait_dom_change(page: Page, chart_selector: str, prev_html: str): + """ + Wait until the target chart element's innerHTML changes from prev_html. + """ + page.wait_for_function( + "(args) => { const [sel, prev] = args; const el = document.querySelector(sel); return el && el.innerHTML !== prev; }", + arg=[chart_selector, prev_html], + ) + + +def assert_chart_changes_by_three_steps(page: Page, chart_selector: str): + """Test chart reactivity across month/hour/both filter changes.""" + base_hash = _chart_state_hash(page, chart_selector) + base_html = page.locator(chart_selector).first.inner_html() + + changed = False + for months in NARROW_MONTHS: + apply_filter(page, months, BASELINE_HOUR) + _wait_dom_change(page, chart_selector, base_html) + base_html = page.locator(chart_selector).first.inner_html() + if _chart_state_hash(page, chart_selector) != base_hash: + changed = True + break + + if not changed: + for hours in NARROW_HOURS: + apply_filter(page, BASELINE_MONTH, hours) + _wait_dom_change(page, chart_selector, base_html) + base_html = page.locator(chart_selector).first.inner_html() + if _chart_state_hash(page, chart_selector) != base_hash: + changed = True + break + + if not changed: + months, hours = NARROW_MONTHS[0], NARROW_HOURS[0] + apply_filter(page, months, hours) + _wait_dom_change(page, chart_selector, base_html) + page.locator(chart_selector).first.inner_html() + if _chart_state_hash(page, chart_selector) != base_hash: + changed = True + + assert changed, ( + f"Chart did not change after filter steps for selector {chart_selector}" + ) + + +PAGES = [ + ("summary", "/summary", ["#degree-days-chart-wrapper"]), + ("t_rh", "/t-rh", ["#heatmap", "#daily", "#yearly-chart"]), + ("sun", "/sun", ["#custom-sunpath", "#monthly-solar", "#cloud-cover"]), + ("wind", "/wind", ["#wind-rose", "#wind-speed", "#wind-direction"]), + ("psy", "/psy-chart", ["#psych-chart"]), + ("outdoor", "/outdoor", ["#utci-heatmap", "#utci-summary-chart"]), + ("explorer", "/explorer", ["#yearly-explore", "#query-daily", "#custom-heatmap"]), +] + + +@pytest.fixture(scope="function", autouse=True) +def _bootstrap_each(page: Page, base_url: str): + page.goto(base_url) + upload_epw_file(page) + yield + + +@pytest.mark.parametrize("page_name,path,selectors", PAGES, ids=[p[0] for p in PAGES]) +def test_time_filter_affects_page( + page: Page, base_url: str, page_name: str, path: str, selectors: List[str] +): + """Verify that charts react to month/hour filters (no screenshot, no timeout).""" + page.goto(f"{base_url}{path}") + open_tools_menu_and_filter_section(page) + + target = None + for sel in selectors: + loc = page.locator(sel).first + try: + loc.scroll_into_view_if_needed() + except Exception: + pass + if loc.is_visible(): + target = sel + break + if not target: + target = selectors[0] + page.locator(target).first.wait_for(state="visible") + + assert_chart_changes_by_three_steps(page, target) diff --git a/tests/test_natural_ventilation.py b/tests/test_natural_ventilation.py new file mode 100644 index 00000000..166ccc43 --- /dev/null +++ b/tests/test_natural_ventilation.py @@ -0,0 +1,58 @@ +import pytest +from playwright.sync_api import Page, expect +from utils import upload_epw_file, open_tab + + +@pytest.fixture(scope="function", autouse=True) +def setup(page: Page, base_url): + """Setup: open app, upload EPW file, and go to Natural Ventilation tab""" + page.goto(base_url) + upload_epw_file(page) + open_tab(page, "Natural Ventilation") + yield + + +# -------------------- Test Core Elements -------------------- +def test_nv_core_elements(page: Page): + """Verify title, charts, filters, and switch are visible""" + + # Main title and charts + expect(page.get_by_text("Natural Ventilation Potential")).to_be_visible() + expect(page.locator("#nv-heatmap-chart")).to_be_visible() + expect(page.locator("#nv-bar-chart")).to_be_visible() + + # Filters and inputs + element_ids = [ + "#nv-dbt-filter", + "#nv-dpt-filter", + "#nv-tdb-min-val", + "#nv-tdb-max-val", + "#nv-dpt-max-val", + "#enable-condensation", + ] + for eid in element_ids: + expect(page.locator(eid)).to_be_visible() + + # Switch label + expect(page.get_by_text("Normalize data")).to_be_visible() + + +# -------------------- Test Filter Button Triggers Chart -------------------- +def test_nv_apply_filter(page: Page): + """Click Dry Bulb filter and verify heatmap still renders""" + button = page.locator("#nv-dbt-filter") + button.click() + expect(page.locator("#nv-heatmap-chart")).to_be_visible() + + +# -------------------- Test Condensation Toggle Effect -------------------- +def test_nv_condensation_checkbox_toggle(page: Page): + """Toggling checkbox should enable/disable dew point filter""" + checkbox = page.locator("input#enable-condensation") + dewpoint_button = page.locator("#nv-dpt-filter") + + expect(dewpoint_button).to_be_disabled() + checkbox.click() + expect(dewpoint_button).to_be_enabled() + checkbox.click() + expect(dewpoint_button).to_be_disabled() diff --git a/tests/test_outdoor.py b/tests/test_outdoor.py new file mode 100644 index 00000000..3f2cc990 --- /dev/null +++ b/tests/test_outdoor.py @@ -0,0 +1,54 @@ +import pytest +from playwright.sync_api import Page, expect +from utils import upload_epw_file, open_tab + + +@pytest.fixture(scope="function", autouse=True) +def setup(page: Page, base_url): + """Setup: Go to base URL, upload EPW file, and open Outdoor page""" + page.goto(base_url) + upload_epw_file(page) + open_tab(page, "Outdoor Comfort") + yield + + +# -------------------- Test Outdoor Page Core Elements -------------------- +def test_outdoor_core_elements(page: Page): + """Verify core UI components (titles, image, charts) are visible""" + # Section titles + expected_texts = [ + "Select a scenario:", + "UTCI heatmap chart", + "UTCI thermal stress chart", + "Normalize data", + "The Best Weather Condition is:", + ] + for text in expected_texts: + expect(page.get_by_text(text)).to_be_visible() + + # Image and switch + expect(page.locator("#image-selection")).to_be_visible() + + # Charts + chart_ids = [ + "#utci-heatmap", + "#utci-category-heatmap", + "#utci-summary-chart", + ] + for cid in chart_ids: + expect(page.locator(cid)).to_be_visible() + + +# -------------------- Test Dropdown Interaction -------------------- +def test_outdoor_dropdown_interaction(page: Page): + """Switch scenario in dropdown and verify all charts reload""" + dropdown = page.locator("#outdoor-dropdown") + dropdown.click() + page.get_by_text("UTCI: Sun & no Wind", exact=True).click() + + for chart_id in [ + "#utci-heatmap", + "#utci-category-heatmap", + "#utci-summary-chart", + ]: + expect(page.locator(chart_id)).to_be_visible() diff --git a/tests/test_psy-chart.py b/tests/test_psy-chart.py new file mode 100644 index 00000000..3267ddc3 --- /dev/null +++ b/tests/test_psy-chart.py @@ -0,0 +1,39 @@ +import pytest +from playwright.sync_api import Page, expect +from utils import upload_epw_file, open_tab + + +@pytest.fixture(scope="function", autouse=True) +def setup(page: Page, base_url): + """Setup: Go to base URL, upload EPW, and open /psy-chart page""" + page.goto(base_url) + upload_epw_file(page) + open_tab(page, "Psychrometric Chart") + yield + + +# -------------------- Test Psy Page Core Elements -------------------- +def test_psy_core_elements(page: Page): + """Verify page title, controls, and chart are visible""" + # Title + expect(page.get_by_role("heading", name="Psychrometric Chart")).to_be_visible() + + # Controls + expect(page.locator("#psy-var-dropdown")).to_be_visible() + expect(page.locator("#psy-min-val")).to_be_visible() + expect(page.locator("#psy-max-val")).to_be_visible() + expect(page.get_by_role("button", name="Apply filter")).to_be_visible() + + # Chart + expect(page.locator("#psych-chart")).to_be_visible() + + +# -------------------- Test Interaction: Dropdown Switch -------------------- +def test_dropdown_change_triggers_chart(page: Page): + """Switch dropdown to another variable and ensure chart re-renders""" + dropdown = page.locator("#psy-color-by-dropdown") + dropdown.click() + page.get_by_text("Dry bulb temperature").click() + + # After dropdown change, chart should still be visible + expect(page.locator("#psych-chart")).to_be_visible() diff --git a/tests/test_select.py b/tests/test_select.py new file mode 100644 index 00000000..67bbcfc7 --- /dev/null +++ b/tests/test_select.py @@ -0,0 +1,42 @@ +import pytest +from playwright.sync_api import Page, expect +from utils import upload_epw_file + + +@pytest.fixture(scope="function", autouse=True) +def setup(page: Page, base_url): + page.goto(f"{base_url}") + yield + + +# -------------------- Test Select Page Core Elements -------------------- +def test_select_core_elements(page: Page): + """Verify that the Select Weather File page loads and shows basic elements""" + # Main text and alerts + expect(page.locator("text=Select an EPW file from your computer")).to_be_visible() + expect(page.locator("#alert")).to_contain_text("upload an EPW file") + + # Upload button + upload_button = page.locator("#upload-data-button") + expect(upload_button).to_be_visible() + expect(upload_button).to_contain_text("Select an EPW file") + + # Upload section container + expect(page.locator("#upload-data")).to_be_visible() + + +# -------------------- Test EPW Upload and Map Rendering -------------------- +def test_upload_and_map_rendering(page: Page): + """ + Simulate uploading an EPW file, verify success message and map rendering + """ + upload_epw_file(page) + + # Confirm success alert + alert_box = page.locator("#alert") + expect(alert_box).to_be_visible() + expect(alert_box).to_contain_text("EPW was successfully loaded!") + + # Map rendered after file upload + map_container = page.locator("#tab-one-map") + expect(map_container).to_be_visible() diff --git a/tests/test_summary.py b/tests/test_summary.py new file mode 100644 index 00000000..ccf51650 --- /dev/null +++ b/tests/test_summary.py @@ -0,0 +1,93 @@ +import pytest +from playwright.sync_api import Page, expect +from utils import upload_epw_file, open_tab + + +@pytest.fixture(scope="function", autouse=True) +def setup(page: Page, base_url): + """Setup: open base URL, upload EPW file, and navigate to /summary page""" + page.goto(base_url) + upload_epw_file(page) + open_tab(page, "Climate Summary") + yield + + +# -------------------- Test Summary Page Core Elements -------------------- +def test_summary_core_elements(page: Page): + """ + Verify all main static elements are visible: + - Section titles + - Download buttons + - Degree day inputs + - Climate profile graphs + """ + # Titles + expect(page.get_by_role("heading", name="Download")).to_be_visible() + expect(page.get_by_text("Heating and Cooling Degree Days")).to_be_visible() + expect(page.get_by_text("Climate Profiles")).to_be_visible() + + # Download buttons + expect(page.get_by_role("button", name="Download EPW")).to_be_visible() + expect(page.get_by_role("button", name="Download Clima dataframe")).to_be_visible() + + # Degree day controls + expect(page.locator("#input-hdd-set-point")).to_be_visible() + expect(page.locator("#input-cdd-set-point")).to_be_visible() + expect(page.locator("#submit-set-points")).to_be_visible() + expect(page.locator("#degree-days-chart-wrapper")).to_be_visible() + + # Climate profile graphs + profile_graphs = [ + "#temp-profile-graph", + "#humidity-profile-graph", + "#solar-radiation-graph", + "#wind-speed-graph", + ] + for graph in profile_graphs: + expect(page.locator(graph)).to_be_visible() + + +# -------------------- Test Location Info Load -------------------- +def test_location_info_loaded(page: Page): + """Check if location info section displays properly""" + """Verify that location info section shows correct values""" + info_section = page.locator("#location-info") + expect(info_section).to_be_visible() + expected_texts = [ + "Location: Bologna Marconi AP, ITA", + "Longitude: 11.2969", + "Latitude: 44.5308", + "Elevation above sea level: 37.0 m", + "This file is based on data collected between 2004 and 2018", + "Köppen-Geiger climate zone: Cfa. Humid subtropical, no dry season.", + "Average yearly temperature: 14.5 °C", + "Hottest yearly temperature (99%): 34.0 °C", + "Coldest yearly temperature (1%): -2.0 °C", + "Annual cumulative horizontal solar radiation: 1546.12 kWh/m2", + "Percentage of diffuse horizontal solar radiation: 39.4 %", + ] + for text in expected_texts: + expect(info_section).to_contain_text(text) + + expect(page.locator("#world-map")).to_be_visible() + + +# -------------------- Test SI/IP System Toggle -------------------- +def test_unit_switch(page: Page): + """ + Verify that the banner radio buttons (SI/IP) correctly toggle. + """ + # nav_controls = page.locator("#nav-group-controls") + # nav_controls.click(force=True) + + # Click the "IP" option + ip_button = page.get_by_text("IP", exact=True) + expect(ip_button).to_be_enabled() + ip_button.scroll_into_view_if_needed() + ip_button.wait_for(state="visible") + ip_button.click(force=True) + + info_section = page.locator("#location-info") + expect(info_section.get_by_text("58.0 °F")).to_be_visible() + expect(info_section.get_by_text("121.4 ft")).to_be_visible() + expect(info_section.get_by_text("kBtu/ft2")).to_be_visible() diff --git a/tests/test_sun.py b/tests/test_sun.py new file mode 100644 index 00000000..36e90d54 --- /dev/null +++ b/tests/test_sun.py @@ -0,0 +1,69 @@ +import pytest +from playwright.sync_api import Page, expect +from utils import upload_epw_file, open_tab + + +@pytest.fixture(scope="function", autouse=True) +def setup(page: Page, base_url): + """Setup: open base URL, upload EPW file, and navigate to /sun page""" + page.goto(base_url) + upload_epw_file(page) + open_tab(page, "Sun and Clouds") + yield + + +# -------------------- Test Sun Page Core Elements -------------------- +def test_sun_core_elements(page: Page): + """ + Verify core layout elements are visible: titles, dropdowns, charts. + """ + # Titles + expected_titles = [ + "Sun path chart", + "Global and Diffuse Horizontal Solar Radiation", + "Cloud coverage", + "Daily charts", + ] + for title in expected_titles: + expect(page.get_by_text(title)).to_be_visible() + + # Dropdowns + dropdown_ids = [ + "#custom-sun-view-dropdown", + "#custom-sun-var-dropdown", + "#sun-explore-dropdown", + ] + for did in dropdown_ids: + expect(page.locator(did)).to_be_visible() + + # Charts + chart_ids = [ + "#custom-sunpath", # Sun path chart + "#monthly-solar", # Global & Diffuse + "#cloud-cover", # Cloud coverage + "#sun-daily", # Daily line chart + "#sun-heatmap", # Daily heatmap + ] + for cid in chart_ids: + expect(page.locator(cid)).to_be_visible() + + +# -------------------- Test Sun Path Chart View and Variable Switching -------------------- +def test_sun_path_switch_view_and_variable(page: Page): + """Switch view and variable in dropdown and check chart re-renders in sun path chart""" + view_dropdown = page.locator("#custom-sun-view-dropdown") + view_dropdown.click() + page.get_by_text("Cartesian").click() + var_dropdown = page.locator("#custom-sun-var-dropdown") + var_dropdown.click() + page.get_by_text("Relative humidity").click() + expect(page.locator("#custom-sunpath")).to_be_visible() + + +# -------------------- Test Daily Chart Variable Switching -------------------- +def test_daily_switch_variable(page: Page): + """Switch view and variable in dropdown and check chart re-renders in daily chart""" + var_dropdown = page.locator("#sun-explore-dropdown") + var_dropdown.click() + page.get_by_text("Direct normal illuminance").click() + expect(page.locator("#sun-daily")).to_be_visible() diff --git a/tests/test_t_rh.py b/tests/test_t_rh.py new file mode 100644 index 00000000..87a930f9 --- /dev/null +++ b/tests/test_t_rh.py @@ -0,0 +1,66 @@ +import pytest +from playwright.sync_api import Page, expect +from utils import upload_epw_file, open_tab + + +@pytest.fixture(scope="function", autouse=True) +def setup(page: Page, base_url): + """Setup: open base URL, upload EPW file, and navigate to /t_rh page""" + page.goto(base_url) + upload_epw_file(page) + open_tab(page, "Temperature and Humidity") + yield + + +# -------------------- Test Temperature and Humidity Page Core Elements -------------------- +def test_t_rh_core_elements(page: Page): + """Test visibility of section titles, chart containers, and statistics table""" + + # Section titles + section_titles = [ + "Yearly Chart", + "Daily chart", + "Heatmap chart", + "Descriptive statistics", + ] + for title in section_titles: + expect(page.get_by_text(title)).to_be_visible() + + # Charts + chart_ids = ["#yearly-chart", "#daily", "#heatmap"] + for cid in chart_ids: + expect(page.locator(cid)).to_be_visible() + + # Table + table = page.locator("#table-tmp-hum") + expect(table).to_be_visible() + + expected_columns = ["month", "mean", "min", "max", "std", "Jun", "Year"] + for col in expected_columns: + expect(table).to_contain_text(col) + + +# -------------------- Test Variable Switching -------------------- +def test_switch_variable_and_rerender(page: Page): + """Switch variable in dropdown and check chart re-renders""" + dropdown = page.locator("#dropdown") + dropdown.click() + page.get_by_text("Relative humidity").click() + # Re-check visibility of charts after switching variable + expect(page.locator("#yearly-chart")).to_be_visible() + expect(page.locator("#daily")).to_be_visible() + expect(page.locator("#heatmap")).to_be_visible() + + +# -------------------- Test Global/Local System Toggle -------------------- +def test_banner_unit_switch(page: Page): + """ + Verify that the banner radio buttons (Global/Local) correctly toggle. + """ + # Click the "Global" option + global_button = page.get_by_text("Global", exact=True) + global_button.scroll_into_view_if_needed() + global_button.wait_for(state="visible") + global_button.click() + + expect(page.get_by_text("-40", exact=False)).to_be_visible() diff --git a/tests/test_wind.py b/tests/test_wind.py new file mode 100644 index 00000000..caf524c2 --- /dev/null +++ b/tests/test_wind.py @@ -0,0 +1,49 @@ +import pytest +from playwright.sync_api import Page, expect +from utils import upload_epw_file, open_tab + + +@pytest.fixture(scope="function", autouse=True) +def setup(page: Page, base_url): + """Setup: open base URL, upload EPW file, and navigate to /wind page""" + page.goto(base_url) + upload_epw_file(page) + open_tab(page, "Wind") + yield + + +# -------------------- Test Wind Page Core Elements -------------------- +def test_wind_core_elements(page: Page): + """Test core visibility and content on Wind page""" + + # Main titles + for title in ["Annual Wind Rose", "Seasonal Wind Rose", "Daily Wind Rose"]: + expect(page.get_by_text(title)).to_be_visible() + + # All wind rose charts + wind_rose_ids = [ + "#wind-rose", + "#winter-wind-rose", + "#spring-wind-rose", + "#summer-wind-rose", + "#fall-wind-rose", + "#morning-wind-rose", + "#noon-wind-rose", + "#night-wind-rose", + ] + for wid in wind_rose_ids: + expect(page.locator(wid)).to_be_visible() + + # Description texts + expected_texts = { + "#winter-wind-rose-text": "Dec and Feb", + "#spring-wind-rose-text": "Mar and May", + "#summer-wind-rose-text": "Jun and Aug", + "#fall-wind-rose-text": "Sep and Dec", + "#morning-wind-rose-text": "6:00 hours and 13:00 hours", + "#noon-wind-rose-text": "14:00 hours and 21:00 hours", + "#night-wind-rose-text": "22:00 hours and 5:00 hours", + } + + for cid, expected in expected_texts.items(): + expect(page.locator(cid)).to_contain_text(expected) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 00000000..9db73505 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,40 @@ +from pathlib import Path +from playwright.sync_api import Page, expect + + +def upload_epw_file( + page: Page, + filename: str = "test.epw", + expected_location: str = "Bologna Marconi AP, ITA", +): + """ + Upload an EPW file and verify that the success message appears. + + Parameters: + - page: The Playwright Page object. + - filename: Path to the EPW file (defaults to tests/test.epw). + - expected_location: Expected location string to verify after upload. + """ + epw_path = Path(filename).resolve() + page.set_input_files('input[type="file"]', str(epw_path)) + + # Verify that the upload success messages are displayed + expect(page.get_by_text("The EPW was successfully loaded!")).to_be_visible() + expect(page.get_by_text(f"Current Location: {expected_location}")).to_be_visible() + + +def open_tab(page: Page, tab_name: str): + """ + Open a specific tab from the sidebar navigation (default expanded version). + Works reliably for Mantine NavLink structure. + """ + # Find the navigation link container whose id starts with "nav-" and text matches + nav_link = page.locator(f'[id^="nav-"] >> text="{tab_name}"').first + + # Go up to the clickable element ( or