climpact-web 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- climpact_web/__init__.py +7 -0
- climpact_web/__main__.py +7 -0
- climpact_web/batch/__init__.py +0 -0
- climpact_web/batch/apps.py +13 -0
- climpact_web/batch/check.py +542 -0
- climpact_web/batch/forms.py +179 -0
- climpact_web/batch/migrations/0001_initial.py +62 -0
- climpact_web/batch/migrations/__init__.py +0 -0
- climpact_web/batch/models.py +126 -0
- climpact_web/batch/retention.py +40 -0
- climpact_web/batch/sample_data/climpact.sample.batch.metadata.txt +3 -0
- climpact_web/batch/templates/batch/_options.html +26 -0
- climpact_web/batch/templates/batch/_progress.html +126 -0
- climpact_web/batch/templates/batch/_retention.html +8 -0
- climpact_web/batch/templates/batch/check.html +155 -0
- climpact_web/batch/templates/batch/results.html +33 -0
- climpact_web/batch/templates/batch/start.html +84 -0
- climpact_web/batch/urls.py +19 -0
- climpact_web/batch/views.py +585 -0
- climpact_web/charts/__init__.py +0 -0
- climpact_web/charts/apps.py +7 -0
- climpact_web/charts/data.py +506 -0
- climpact_web/charts/sample_outputs/sydney/indices/sydney_3month_spi_MON.csv +968 -0
- climpact_web/charts/sample_outputs/sydney/indices/sydney_prcptot_ANN.csv +88 -0
- climpact_web/charts/sample_outputs/sydney/indices/sydney_txx_ANN.csv +88 -0
- climpact_web/charts/sample_outputs/sydney/indices/sydney_txx_MON.csv +968 -0
- climpact_web/charts/sample_outputs/sydney/trend/sydney_trend.csv +24 -0
- climpact_web/charts/templatetags/__init__.py +0 -0
- climpact_web/charts/templatetags/charts.py +56 -0
- climpact_web/charts/urls.py +14 -0
- climpact_web/charts/views.py +255 -0
- climpact_web/cli.py +308 -0
- climpact_web/core/__init__.py +0 -0
- climpact_web/core/apps.py +9 -0
- climpact_web/core/context_processors.py +14 -0
- climpact_web/core/forms.py +47 -0
- climpact_web/core/management/__init__.py +0 -0
- climpact_web/core/management/commands/__init__.py +0 -0
- climpact_web/core/management/commands/design_snapshots.py +293 -0
- climpact_web/core/navigation.py +161 -0
- climpact_web/core/static/climpact_web/chart-palette.json +85 -0
- climpact_web/core/static/climpact_web/css/climpact.css +2 -0
- climpact_web/core/static/climpact_web/icons/favicon.svg +1 -0
- climpact_web/core/static/climpact_web/icons/icons.svg +46 -0
- climpact_web/core/static/climpact_web/js/charts.js +979 -0
- climpact_web/core/static/climpact_web/js/gridded.js +21 -0
- climpact_web/core/static/climpact_web/js/qc.js +108 -0
- climpact_web/core/static/climpact_web/js/results.js +78 -0
- climpact_web/core/static/climpact_web/js/sector.js +18 -0
- climpact_web/core/static/climpact_web/js/tour.js +191 -0
- climpact_web/core/static/climpact_web/js/ui.js +353 -0
- climpact_web/core/static/climpact_web/vendor/htmx/README.md +14 -0
- climpact_web/core/static/climpact_web/vendor/htmx/htmx.min.js +1 -0
- climpact_web/core/static/climpact_web/vendor/uplot/LICENSE +21 -0
- climpact_web/core/static/climpact_web/vendor/uplot/README.md +23 -0
- climpact_web/core/static/climpact_web/vendor/uplot/uPlot.iife.min.js +2 -0
- climpact_web/core/static/climpact_web/vendor/uplot/uPlot.min.css +1 -0
- climpact_web/core/storage.py +70 -0
- climpact_web/core/templates/404.html +7 -0
- climpact_web/core/templates/413.html +9 -0
- climpact_web/core/templates/500.html +20 -0
- climpact_web/core/templates/base.html +79 -0
- climpact_web/core/templates/components/alert.html +15 -0
- climpact_web/core/templates/components/badge.html +2 -0
- climpact_web/core/templates/components/button.html +9 -0
- climpact_web/core/templates/components/card.html +10 -0
- climpact_web/core/templates/components/chart_frame.html +62 -0
- climpact_web/core/templates/components/dialog.html +20 -0
- climpact_web/core/templates/components/disclosure.html +5 -0
- climpact_web/core/templates/components/dropzone.html +15 -0
- climpact_web/core/templates/components/empty_state.html +7 -0
- climpact_web/core/templates/components/field.html +33 -0
- climpact_web/core/templates/components/field_errors.html +1 -0
- climpact_web/core/templates/components/form_errors.html +16 -0
- climpact_web/core/templates/components/messages.html +7 -0
- climpact_web/core/templates/components/progress.html +9 -0
- climpact_web/core/templates/components/skeleton.html +10 -0
- climpact_web/core/templates/components/stages.html +12 -0
- climpact_web/core/templates/components/stat.html +2 -0
- climpact_web/core/templates/components/steps.html +20 -0
- climpact_web/core/templates/components/summary_list.html +4 -0
- climpact_web/core/templates/components/table.html +18 -0
- climpact_web/core/templates/components/tabs.html +17 -0
- climpact_web/core/templates/components/theme_toggle.html +6 -0
- climpact_web/core/templates/components/toasts.html +12 -0
- climpact_web/core/templates/core/home.html +117 -0
- climpact_web/core/templates/core/language_switcher.html +12 -0
- climpact_web/core/templates/core/patterns.html +291 -0
- climpact_web/core/templates/core/patterns_table.html +1 -0
- climpact_web/core/templatetags/__init__.py +0 -0
- climpact_web/core/templatetags/ui.py +63 -0
- climpact_web/core/tokens.py +90 -0
- climpact_web/core/urls.py +11 -0
- climpact_web/core/views.py +189 -0
- climpact_web/gridded/__init__.py +0 -0
- climpact_web/gridded/apps.py +13 -0
- climpact_web/gridded/forms.py +499 -0
- climpact_web/gridded/inspection.py +412 -0
- climpact_web/gridded/maps.py +444 -0
- climpact_web/gridded/migrations/0001_initial.py +73 -0
- climpact_web/gridded/migrations/__init__.py +0 -0
- climpact_web/gridded/models.py +109 -0
- climpact_web/gridded/retention.py +44 -0
- climpact_web/gridded/sample_data/climpact.sampledata.gridded.3x4.1991-2010.nc +0 -0
- climpact_web/gridded/templates/gridded/_browser.html +23 -0
- climpact_web/gridded/templates/gridded/_checks.html +29 -0
- climpact_web/gridded/templates/gridded/_chooser.html +27 -0
- climpact_web/gridded/templates/gridded/_explore.html +131 -0
- climpact_web/gridded/templates/gridded/_progress.html +93 -0
- climpact_web/gridded/templates/gridded/_retention.html +1 -0
- climpact_web/gridded/templates/gridded/_series_table.html +8 -0
- climpact_web/gridded/templates/gridded/results.html +27 -0
- climpact_web/gridded/templates/gridded/setup.html +196 -0
- climpact_web/gridded/templates/gridded/start.html +73 -0
- climpact_web/gridded/urls.py +23 -0
- climpact_web/gridded/views.py +892 -0
- climpact_web/help/__init__.py +0 -0
- climpact_web/help/apps.py +8 -0
- climpact_web/help/guides/en/accessibility.md +89 -0
- climpact_web/help/guides/en/base-period.md +70 -0
- climpact_web/help/guides/en/batch.md +93 -0
- climpact_web/help/guides/en/faq.md +72 -0
- climpact_web/help/guides/en/gridded.md +86 -0
- climpact_web/help/guides/en/indices.md +137 -0
- climpact_web/help/guides/en/quality-control.md +113 -0
- climpact_web/help/guides/en/sector.md +77 -0
- climpact_web/help/guides/en/station-file.md +89 -0
- climpact_web/help/guides/en/trends.md +67 -0
- climpact_web/help/guides.py +288 -0
- climpact_web/help/index_messages.py +145 -0
- climpact_web/help/indices.py +310 -0
- climpact_web/help/templates/help/_results.html +20 -0
- climpact_web/help/templates/help/_search_form.html +15 -0
- climpact_web/help/templates/help/_topic.html +11 -0
- climpact_web/help/templates/help/_tour.html +16 -0
- climpact_web/help/templates/help/guide.html +35 -0
- climpact_web/help/templates/help/index.html +67 -0
- climpact_web/help/templates/help/search.html +11 -0
- climpact_web/help/templates/help/topics.html +31 -0
- climpact_web/help/templatetags/__init__.py +0 -0
- climpact_web/help/templatetags/help.py +40 -0
- climpact_web/help/topics.py +795 -0
- climpact_web/help/tour.py +58 -0
- climpact_web/help/urls.py +12 -0
- climpact_web/help/views.py +67 -0
- climpact_web/jobs/__init__.py +0 -0
- climpact_web/jobs/apps.py +10 -0
- climpact_web/jobs/context_processors.py +7 -0
- climpact_web/jobs/forms.py +13 -0
- climpact_web/jobs/management/__init__.py +0 -0
- climpact_web/jobs/management/commands/__init__.py +0 -0
- climpact_web/jobs/management/commands/purgejobs.py +22 -0
- climpact_web/jobs/management/commands/runworker.py +41 -0
- climpact_web/jobs/migrations/0001_initial.py +75 -0
- climpact_web/jobs/migrations/__init__.py +0 -0
- climpact_web/jobs/models.py +170 -0
- climpact_web/jobs/registry.py +76 -0
- climpact_web/jobs/retention.py +99 -0
- climpact_web/jobs/runners.py +587 -0
- climpact_web/jobs/stages.py +41 -0
- climpact_web/jobs/storage.py +76 -0
- climpact_web/jobs/templates/jobs/_progress.html +83 -0
- climpact_web/jobs/templates/jobs/_status.html +2 -0
- climpact_web/jobs/templates/jobs/job_detail.html +20 -0
- climpact_web/jobs/templates/jobs/job_list.html +35 -0
- climpact_web/jobs/urls.py +14 -0
- climpact_web/jobs/views.py +177 -0
- climpact_web/jobs/worker.py +402 -0
- climpact_web/locale/en/LC_MESSAGES/django.po +7828 -0
- climpact_web/middleware.py +69 -0
- climpact_web/settings.py +264 -0
- climpact_web/stations/__init__.py +0 -0
- climpact_web/stations/apps.py +13 -0
- climpact_web/stations/downloads.py +184 -0
- climpact_web/stations/forms.py +295 -0
- climpact_web/stations/forms_indices.py +397 -0
- climpact_web/stations/migrations/0001_initial.py +98 -0
- climpact_web/stations/migrations/0002_batch_origin.py +30 -0
- climpact_web/stations/migrations/0003_qc_params.py +17 -0
- climpact_web/stations/migrations/__init__.py +0 -0
- climpact_web/stations/models.py +180 -0
- climpact_web/stations/preview.py +98 -0
- climpact_web/stations/qc_plots.py +338 -0
- climpact_web/stations/qc_review.py +856 -0
- climpact_web/stations/results.py +793 -0
- climpact_web/stations/retention.py +57 -0
- climpact_web/stations/sample_data/sydney_observatory_hill_1936-2015.txt.gz +0 -0
- climpact_web/stations/sample_data/wheat_yield_nsw_1922-1999.csv +79 -0
- climpact_web/stations/sector_file.py +345 -0
- climpact_web/stations/sector_plots.py +374 -0
- climpact_web/stations/station_file.py +406 -0
- climpact_web/stations/templates/stations/_download_link.html +1 -0
- climpact_web/stations/templates/stations/_indices_unit.html +49 -0
- climpact_web/stations/templates/stations/_problems.html +17 -0
- climpact_web/stations/templates/stations/_qc_box_plot.html +27 -0
- climpact_web/stations/templates/stations/_qc_check.html +14 -0
- climpact_web/stations/templates/stations/_qc_distributions.html +77 -0
- climpact_web/stations/templates/stations/_qc_form.html +20 -0
- climpact_web/stations/templates/stations/_qc_missing.html +40 -0
- climpact_web/stations/templates/stations/_qc_progress.html +26 -0
- climpact_web/stations/templates/stations/_qc_summary.html +54 -0
- climpact_web/stations/templates/stations/_qc_table.html +34 -0
- climpact_web/stations/templates/stations/_results_progress.html +67 -0
- climpact_web/stations/templates/stations/_retention.html +16 -0
- climpact_web/stations/templates/stations/_sector_progress.html +40 -0
- climpact_web/stations/templates/stations/_sector_results.html +94 -0
- climpact_web/stations/templates/stations/_spark.html +1 -0
- climpact_web/stations/templates/stations/_summary.html +105 -0
- climpact_web/stations/templates/stations/_unit.html +33 -0
- climpact_web/stations/templates/stations/downloads.html +104 -0
- climpact_web/stations/templates/stations/indices.html +108 -0
- climpact_web/stations/templates/stations/load.html +62 -0
- climpact_web/stations/templates/stations/qc.html +133 -0
- climpact_web/stations/templates/stations/result.html +118 -0
- climpact_web/stations/templates/stations/results.html +143 -0
- climpact_web/stations/templates/stations/results_progress.html +24 -0
- climpact_web/stations/templates/stations/sector.html +91 -0
- climpact_web/stations/templates/stations/upload.html +73 -0
- climpact_web/stations/urls.py +51 -0
- climpact_web/stations/views.py +530 -0
- climpact_web/stations/views_downloads.py +53 -0
- climpact_web/stations/views_indices.py +545 -0
- climpact_web/stations/views_sector.py +219 -0
- climpact_web/urls.py +14 -0
- climpact_web/wsgi.py +9 -0
- climpact_web-0.1.0.dist-info/METADATA +97 -0
- climpact_web-0.1.0.dist-info/RECORD +230 -0
- climpact_web-0.1.0.dist-info/WHEEL +4 -0
- climpact_web-0.1.0.dist-info/entry_points.txt +2 -0
- climpact_web-0.1.0.dist-info/licenses/LICENSE +675 -0
climpact_web/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Web front end for Climpact for Python (the `climpact` package): a Django + htmx app (epic #11).
|
|
2
|
+
|
|
3
|
+
Run it locally with the ``climpact-web`` command (``climpact_web.cli``); TECHNICAL.md
|
|
4
|
+
("Web front end") describes the layout, the two modes and the settings.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
climpact_web/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BatchConfig(AppConfig):
|
|
5
|
+
name = "climpact_web.batch"
|
|
6
|
+
label = "batch"
|
|
7
|
+
verbose_name = "Batch processing"
|
|
8
|
+
|
|
9
|
+
def ready(self) -> None:
|
|
10
|
+
from climpact_web.batch.retention import purge_expired_batches
|
|
11
|
+
from climpact_web.jobs.retention import register_purger
|
|
12
|
+
|
|
13
|
+
register_purger(purge_expired_batches)
|
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
"""Checking a batch before it runs (#80): the metadata file, then each station it lists.
|
|
2
|
+
|
|
3
|
+
The core (:func:`climpact.batch.read_batch_metadata`) stays the authority on what the
|
|
4
|
+
metadata file means, but its errors are written for the command line and it stops at the
|
|
5
|
+
first. This module walks the file first with the same rules (whitespace-separated
|
|
6
|
+
fields, ``#`` comments and blank lines skipped, the first line a header) and reports every
|
|
7
|
+
problem in plain language with its **line number** and the line itself, as the station
|
|
8
|
+
file check of step 1 does (:mod:`climpact_web.stations.station_file`).
|
|
9
|
+
|
|
10
|
+
Each station is then *ready*, *missing* (no file of that name was uploaded) or *invalid*
|
|
11
|
+
(its file has problems, its name cannot be used, or its location or index parameters are
|
|
12
|
+
out of range), with the reasons. Station files are checked by the single-station check
|
|
13
|
+
(:func:`~climpact_web.stations.station_file.inspect_station_bytes`), so a file accepted
|
|
14
|
+
here is accepted in step 1 and the other way round.
|
|
15
|
+
|
|
16
|
+
Uploads may be loose files or zip files holding them (and the metadata file); see
|
|
17
|
+
:func:`unpack`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
import zipfile
|
|
24
|
+
from dataclasses import asdict, dataclass, field
|
|
25
|
+
from io import BytesIO
|
|
26
|
+
from pathlib import PurePosixPath
|
|
27
|
+
|
|
28
|
+
from django.utils.translation import gettext as _
|
|
29
|
+
from django.utils.translation import ngettext
|
|
30
|
+
|
|
31
|
+
from climpact_web.jobs.forms import STATION_NAME
|
|
32
|
+
from climpact_web.stations.station_file import (
|
|
33
|
+
MAX_PROBLEMS_SHOWN,
|
|
34
|
+
decode,
|
|
35
|
+
inspect_station_bytes,
|
|
36
|
+
shorten,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# The metadata columns, as climpact.batch.COLUMNS (R's read.file.list.metadata).
|
|
40
|
+
COLUMNS = (
|
|
41
|
+
"station_file",
|
|
42
|
+
"latitude",
|
|
43
|
+
"longitude",
|
|
44
|
+
"wsdin",
|
|
45
|
+
"csdin",
|
|
46
|
+
"Tb_HDD",
|
|
47
|
+
"Tb_CDD",
|
|
48
|
+
"Tb_GDD",
|
|
49
|
+
"rxnday",
|
|
50
|
+
"rnnmm",
|
|
51
|
+
"txtn",
|
|
52
|
+
"SPEI",
|
|
53
|
+
)
|
|
54
|
+
INTEGER_COLUMNS = {"wsdin", "csdin", "rxnday", "txtn", "SPEI"}
|
|
55
|
+
# The metadata columns as IndexParams fields (climpact.batch.read_batch_metadata).
|
|
56
|
+
PARAM_FIELDS = {
|
|
57
|
+
"wsdin": "wsdi_days",
|
|
58
|
+
"csdin": "csdi_days",
|
|
59
|
+
"Tb_HDD": "hdd_base",
|
|
60
|
+
"Tb_CDD": "cdd_base",
|
|
61
|
+
"Tb_GDD": "gdd_base",
|
|
62
|
+
"rxnday": "rx_days",
|
|
63
|
+
"rnnmm": "rnnmm",
|
|
64
|
+
"txtn": "txtn_days",
|
|
65
|
+
"SPEI": "spei_months",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
MAX_FILES = 500 # station files in one batch
|
|
69
|
+
MAX_UNPACKED_BYTES = 500 * 1024 * 1024 # all files of the zips, uncompressed
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def column_label(column: str) -> str:
|
|
73
|
+
"""A metadata column as the user guide describes it, with its name in the file."""
|
|
74
|
+
labels = {
|
|
75
|
+
"station_file": _("station file"),
|
|
76
|
+
"latitude": _("latitude"),
|
|
77
|
+
"longitude": _("longitude"),
|
|
78
|
+
"wsdin": _("wsdin (days for WSDI)"),
|
|
79
|
+
"csdin": _("csdin (days for CSDI)"),
|
|
80
|
+
"Tb_HDD": _("Tb_HDD (base temperature for heating degree days)"),
|
|
81
|
+
"Tb_CDD": _("Tb_CDD (base temperature for cooling degree days)"),
|
|
82
|
+
"Tb_GDD": _("Tb_GDD (base temperature for growing degree days)"),
|
|
83
|
+
"rxnday": _("rxnday (days for Rxnday)"),
|
|
84
|
+
"rnnmm": _("rnnmm (millimetres for Rnnmm)"),
|
|
85
|
+
"txtn": _("txtn (days for TXdTNd and TXbdTNbd)"),
|
|
86
|
+
"SPEI": _("SPEI (months for the custom SPEI and SPI)"),
|
|
87
|
+
}
|
|
88
|
+
return labels[column]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class Problem:
|
|
93
|
+
message: str
|
|
94
|
+
line: int | None = None
|
|
95
|
+
text: str = ""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class StationCheck:
|
|
100
|
+
"""One row of the metadata file, and what was found for it."""
|
|
101
|
+
|
|
102
|
+
name: str
|
|
103
|
+
station_file: str
|
|
104
|
+
line: int
|
|
105
|
+
latitude: float
|
|
106
|
+
longitude: float
|
|
107
|
+
params: dict # IndexParams fields from the metadata row
|
|
108
|
+
status: str = "ready" # "ready", "missing" or "invalid"
|
|
109
|
+
problems: list[dict] = field(default_factory=list)
|
|
110
|
+
problem_count: int = 0
|
|
111
|
+
warnings: list[str] = field(default_factory=list)
|
|
112
|
+
summary: dict | None = None # period and missing data, from the file
|
|
113
|
+
|
|
114
|
+
def add(self, message: str, line: int | None = None, text: str = "") -> None:
|
|
115
|
+
self.problem_count += 1
|
|
116
|
+
if len(self.problems) < MAX_PROBLEMS_SHOWN:
|
|
117
|
+
self.problems.append(asdict(Problem(message, line, text)))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class BatchCheck:
|
|
122
|
+
"""Everything the check page shows: as JSON in ``BatchRun.check``."""
|
|
123
|
+
|
|
124
|
+
metadata_problems: list[dict] = field(default_factory=list)
|
|
125
|
+
metadata_problem_count: int = 0
|
|
126
|
+
metadata_warnings: list[str] = field(default_factory=list)
|
|
127
|
+
stations: list[StationCheck] = field(default_factory=list)
|
|
128
|
+
extra_files: list[str] = field(default_factory=list)
|
|
129
|
+
upload_problems: list[str] = field(default_factory=list)
|
|
130
|
+
|
|
131
|
+
def add(self, message: str, line: int | None = None, text: str = "") -> None:
|
|
132
|
+
self.metadata_problem_count += 1
|
|
133
|
+
if len(self.metadata_problems) < MAX_PROBLEMS_SHOWN:
|
|
134
|
+
self.metadata_problems.append(asdict(Problem(message, line, text)))
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def metadata_ok(self) -> bool:
|
|
138
|
+
return not self.metadata_problem_count
|
|
139
|
+
|
|
140
|
+
def to_json(self) -> dict:
|
|
141
|
+
return asdict(self)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---- Uploads ----------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class Unpacked:
|
|
149
|
+
"""The uploaded files by name, the metadata file found in a zip (if any), and
|
|
150
|
+
problems with the uploads themselves."""
|
|
151
|
+
|
|
152
|
+
files: dict[str, bytes] = field(default_factory=dict)
|
|
153
|
+
metadata: tuple[str, bytes] | None = None
|
|
154
|
+
problems: list[str] = field(default_factory=list)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def plain_name(name: str) -> str | None:
|
|
158
|
+
"""A file name without any folder, or None when it cannot be a station file name
|
|
159
|
+
(empty, hidden, or a folder)."""
|
|
160
|
+
base = PurePosixPath(name.replace("\\", "/")).name
|
|
161
|
+
if not base or base.startswith(".") or len(base) > 255:
|
|
162
|
+
return None
|
|
163
|
+
return base
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def looks_like_metadata(raw: bytes) -> bool:
|
|
167
|
+
"""A batch metadata file: a first line of 12 column names starting ``station_file``."""
|
|
168
|
+
text = decode(raw[:4096])
|
|
169
|
+
if text is None:
|
|
170
|
+
return False
|
|
171
|
+
for line in text.split("\n"):
|
|
172
|
+
fields = line.split("#", 1)[0].split()
|
|
173
|
+
if fields:
|
|
174
|
+
return len(fields) == len(COLUMNS) and fields[0].lower() == "station_file"
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def unpack(uploads: list[tuple[str, bytes]]) -> Unpacked:
|
|
179
|
+
"""Station files from loose files and zip files, by name without folders.
|
|
180
|
+
|
|
181
|
+
A zip may hold the station files in any folders, and the metadata file too (the file
|
|
182
|
+
whose first line is the metadata header). Hidden files and macOS's ``__MACOSX``
|
|
183
|
+
folders are ignored. A name given twice keeps the last file and says so."""
|
|
184
|
+
out = Unpacked()
|
|
185
|
+
unpacked_bytes = 0
|
|
186
|
+
metadata_found: list[tuple[str, bytes]] = []
|
|
187
|
+
|
|
188
|
+
def add(name: str, data: bytes, origin: str = "") -> None:
|
|
189
|
+
base = plain_name(name)
|
|
190
|
+
if base is None:
|
|
191
|
+
return
|
|
192
|
+
if base in out.files:
|
|
193
|
+
out.problems.append(
|
|
194
|
+
_("Two files are named %(name)s; the last one is used.") % {"name": base}
|
|
195
|
+
)
|
|
196
|
+
if len(out.files) >= MAX_FILES and base not in out.files:
|
|
197
|
+
if len(out.files) == MAX_FILES:
|
|
198
|
+
out.problems.append(
|
|
199
|
+
_("Only the first %(count)d files are used.") % {"count": MAX_FILES}
|
|
200
|
+
)
|
|
201
|
+
return
|
|
202
|
+
out.files[base] = data
|
|
203
|
+
|
|
204
|
+
for name, data in uploads:
|
|
205
|
+
if data[:4] == b"PK\x03\x04" and name.lower().endswith(".zip"):
|
|
206
|
+
try:
|
|
207
|
+
archive = zipfile.ZipFile(BytesIO(data))
|
|
208
|
+
infos = archive.infolist()
|
|
209
|
+
except zipfile.BadZipFile:
|
|
210
|
+
out.problems.append(
|
|
211
|
+
_("%(name)s is not a zip file that can be opened.") % {"name": name}
|
|
212
|
+
)
|
|
213
|
+
continue
|
|
214
|
+
for info in infos:
|
|
215
|
+
path = info.filename
|
|
216
|
+
if info.is_dir() or path.startswith("__MACOSX/") or plain_name(path) is None:
|
|
217
|
+
continue
|
|
218
|
+
unpacked_bytes += info.file_size
|
|
219
|
+
if unpacked_bytes > MAX_UNPACKED_BYTES:
|
|
220
|
+
out.problems.append(
|
|
221
|
+
_(
|
|
222
|
+
"%(name)s holds more data than a batch can use; the rest of it "
|
|
223
|
+
"is left out."
|
|
224
|
+
)
|
|
225
|
+
% {"name": name}
|
|
226
|
+
)
|
|
227
|
+
break
|
|
228
|
+
try:
|
|
229
|
+
content = archive.read(info)
|
|
230
|
+
except (zipfile.BadZipFile, NotImplementedError, RuntimeError):
|
|
231
|
+
out.problems.append(
|
|
232
|
+
_("%(file)s in %(name)s could not be read (damaged or encrypted).")
|
|
233
|
+
% {"file": path, "name": name}
|
|
234
|
+
)
|
|
235
|
+
continue
|
|
236
|
+
if looks_like_metadata(content):
|
|
237
|
+
metadata_found.append((plain_name(path), content))
|
|
238
|
+
else:
|
|
239
|
+
add(path, content)
|
|
240
|
+
else:
|
|
241
|
+
add(name, data)
|
|
242
|
+
|
|
243
|
+
if len(metadata_found) == 1:
|
|
244
|
+
out.metadata = metadata_found[0]
|
|
245
|
+
elif metadata_found:
|
|
246
|
+
out.problems.append(
|
|
247
|
+
_(
|
|
248
|
+
"The zip holds more than one metadata file (%(names)s). Choose the one to use "
|
|
249
|
+
"in the metadata box."
|
|
250
|
+
)
|
|
251
|
+
% {"names": ", ".join(n for n, _ in metadata_found)}
|
|
252
|
+
)
|
|
253
|
+
return out
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---- The metadata file ------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _number(text: str) -> float | None:
|
|
260
|
+
try:
|
|
261
|
+
value = float(text)
|
|
262
|
+
except ValueError:
|
|
263
|
+
return None
|
|
264
|
+
return value if value == value and abs(value) != float("inf") else None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def check_metadata(raw: bytes, result: BatchCheck) -> list[StationCheck]:
|
|
268
|
+
"""The stations of the metadata file, adding a problem for every line the core could
|
|
269
|
+
not read (R's ``read.table`` would stop the whole batch on any of them)."""
|
|
270
|
+
if not raw.strip():
|
|
271
|
+
result.add(_("The metadata file is empty."))
|
|
272
|
+
return []
|
|
273
|
+
text = decode(raw)
|
|
274
|
+
if text is None:
|
|
275
|
+
result.add(
|
|
276
|
+
_(
|
|
277
|
+
"The metadata file is not a text file. If it is a spreadsheet, save it as "
|
|
278
|
+
"text (tab-delimited) with the 12 columns of the template."
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
return []
|
|
282
|
+
rows = []
|
|
283
|
+
for number, line in enumerate(text.split("\n"), start=1):
|
|
284
|
+
fields = line.split("#", 1)[0].split()
|
|
285
|
+
if fields:
|
|
286
|
+
rows.append((number, line, fields))
|
|
287
|
+
if not rows:
|
|
288
|
+
result.add(_("The metadata file is empty."))
|
|
289
|
+
return []
|
|
290
|
+
header_line, header_text, header = rows[0]
|
|
291
|
+
if len(header) == len(COLUMNS) and _number(header[1]) is not None:
|
|
292
|
+
result.metadata_warnings.append(
|
|
293
|
+
_(
|
|
294
|
+
"Line %(line)d looks like a station, not the line of column names. The "
|
|
295
|
+
"first line is always read as column names, so this station is left out, "
|
|
296
|
+
"as in Climpact for R. Add the column names as the first line to include it."
|
|
297
|
+
)
|
|
298
|
+
% {"line": header_line}
|
|
299
|
+
)
|
|
300
|
+
if len(rows) == 1:
|
|
301
|
+
result.add(_("The metadata file has the line of column names but no stations."))
|
|
302
|
+
return []
|
|
303
|
+
|
|
304
|
+
stations = []
|
|
305
|
+
for number, line, fields in rows[1:]:
|
|
306
|
+
if len(fields) != len(COLUMNS):
|
|
307
|
+
message = ngettext(
|
|
308
|
+
"Line %(line)d has %(count)d value; each line needs 12: the station file, "
|
|
309
|
+
"latitude, longitude, wsdin, csdin, Tb_HDD, Tb_CDD, Tb_GDD, rxnday, rnnmm, "
|
|
310
|
+
"txtn and SPEI.",
|
|
311
|
+
"Line %(line)d has %(count)d values; each line needs 12: the station file, "
|
|
312
|
+
"latitude, longitude, wsdin, csdin, Tb_HDD, Tb_CDD, Tb_GDD, rxnday, rnnmm, "
|
|
313
|
+
"txtn and SPEI.",
|
|
314
|
+
len(fields),
|
|
315
|
+
) % {"line": number, "count": len(fields)}
|
|
316
|
+
if "," in line:
|
|
317
|
+
message += " " + _("Separate the values with spaces or tabs, not commas.")
|
|
318
|
+
result.add(message, number, shorten(line))
|
|
319
|
+
continue
|
|
320
|
+
values = {}
|
|
321
|
+
for column, value in zip(COLUMNS[1:], fields[1:], strict=True):
|
|
322
|
+
number_value = _number(value)
|
|
323
|
+
if number_value is None:
|
|
324
|
+
result.add(
|
|
325
|
+
_("Line %(line)d: “%(value)s” in the %(column)s column is not a number.")
|
|
326
|
+
% {"line": number, "value": value[:20], "column": column_label(column)},
|
|
327
|
+
number,
|
|
328
|
+
shorten(line),
|
|
329
|
+
)
|
|
330
|
+
break
|
|
331
|
+
if column in INTEGER_COLUMNS and not number_value.is_integer():
|
|
332
|
+
result.add(
|
|
333
|
+
_("Line %(line)d: %(column)s must be a whole number, not %(value)s.")
|
|
334
|
+
% {"line": number, "value": value[:20], "column": column_label(column)},
|
|
335
|
+
number,
|
|
336
|
+
shorten(line),
|
|
337
|
+
)
|
|
338
|
+
break
|
|
339
|
+
values[column] = int(number_value) if column in INTEGER_COLUMNS else number_value
|
|
340
|
+
else:
|
|
341
|
+
from climpact.batch import strip_file_extension
|
|
342
|
+
|
|
343
|
+
stations.append(
|
|
344
|
+
StationCheck(
|
|
345
|
+
name=strip_file_extension(fields[0]),
|
|
346
|
+
station_file=fields[0],
|
|
347
|
+
line=number,
|
|
348
|
+
latitude=values["latitude"],
|
|
349
|
+
longitude=values["longitude"],
|
|
350
|
+
params={PARAM_FIELDS[c]: values[c] for c in PARAM_FIELDS},
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
seen: dict[str, StationCheck] = {}
|
|
355
|
+
for station in stations:
|
|
356
|
+
first = seen.setdefault(station.name, station)
|
|
357
|
+
if first is not station:
|
|
358
|
+
result.add(
|
|
359
|
+
_(
|
|
360
|
+
"Lines %(first)d and %(line)d both give the station name “%(name)s” "
|
|
361
|
+
"(%(file1)s and %(file2)s). Each station's results go in a folder named "
|
|
362
|
+
"after it, so each needs its own name."
|
|
363
|
+
)
|
|
364
|
+
% {
|
|
365
|
+
"first": first.line,
|
|
366
|
+
"line": station.line,
|
|
367
|
+
"name": station.name,
|
|
368
|
+
"file1": first.station_file,
|
|
369
|
+
"file2": station.station_file,
|
|
370
|
+
},
|
|
371
|
+
station.line,
|
|
372
|
+
)
|
|
373
|
+
if not result.metadata_ok:
|
|
374
|
+
return []
|
|
375
|
+
if not stations:
|
|
376
|
+
result.add(_("The metadata file has the line of column names but no stations."))
|
|
377
|
+
return stations
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ---- Each station -----------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def check_parameters(station: StationCheck) -> None:
|
|
384
|
+
"""R's ``validate_climdexInputParams`` and the location ranges, in plain language.
|
|
385
|
+
In R a bad parameter stops the whole batch; here it stops only its station."""
|
|
386
|
+
p = station.params
|
|
387
|
+
if not -90 <= station.latitude <= 90:
|
|
388
|
+
station.add(
|
|
389
|
+
_("Latitude %(value)s is outside -90 to 90.") % {"value": f"{station.latitude:g}"},
|
|
390
|
+
station.line,
|
|
391
|
+
)
|
|
392
|
+
if not -180 <= station.longitude <= 180:
|
|
393
|
+
station.add(
|
|
394
|
+
_("Longitude %(value)s is outside -180 to 180.") % {"value": f"{station.longitude:g}"},
|
|
395
|
+
station.line,
|
|
396
|
+
)
|
|
397
|
+
rules = [
|
|
398
|
+
("wsdin", not 1 <= p["wsdi_days"] <= 10, _("%(column)s must be between 1 and 10.")),
|
|
399
|
+
("csdin", not 1 <= p["csdi_days"] <= 10, _("%(column)s must be between 1 and 10.")),
|
|
400
|
+
("rxnday", p["rx_days"] < 1, _("%(column)s must be 1 or more.")),
|
|
401
|
+
("txtn", p["txtn_days"] < 1, _("%(column)s must be 1 or more.")),
|
|
402
|
+
("rnnmm", p["rnnmm"] < 0, _("%(column)s must be 0 or more.")),
|
|
403
|
+
("SPEI", p["spei_months"] < 1, _("%(column)s must be 1 or more.")),
|
|
404
|
+
]
|
|
405
|
+
for column, bad, message in rules:
|
|
406
|
+
if bad:
|
|
407
|
+
value = p[PARAM_FIELDS[column]]
|
|
408
|
+
station.add(
|
|
409
|
+
(message % {"column": column_label(column)})
|
|
410
|
+
+ " "
|
|
411
|
+
+ _("It is %(value)s.") % {"value": f"{value:g}"},
|
|
412
|
+
station.line,
|
|
413
|
+
)
|
|
414
|
+
if station.latitude == 0:
|
|
415
|
+
station.warnings.append(
|
|
416
|
+
_(
|
|
417
|
+
"Latitude 0 is treated as the Southern Hemisphere (as in Climpact for R), "
|
|
418
|
+
"which decides the season used for the growing season length and heatwaves."
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def check_name(station: StationCheck) -> bool:
|
|
424
|
+
if "/" in station.station_file or "\\" in station.station_file:
|
|
425
|
+
station.add(
|
|
426
|
+
_(
|
|
427
|
+
"“%(file)s” is a folder path. Give only the file's name, e.g. "
|
|
428
|
+
"%(example)s, and upload the file."
|
|
429
|
+
)
|
|
430
|
+
% {
|
|
431
|
+
"file": station.station_file,
|
|
432
|
+
"example": PurePosixPath(station.station_file.replace("\\", "/")).name,
|
|
433
|
+
},
|
|
434
|
+
station.line,
|
|
435
|
+
)
|
|
436
|
+
return False
|
|
437
|
+
if not re.match(STATION_NAME, station.name):
|
|
438
|
+
station.add(
|
|
439
|
+
_(
|
|
440
|
+
"The station name “%(name)s” (the file name without its extension) cannot "
|
|
441
|
+
"be used: use letters, digits, spaces and . ( ) - _ only, not starting with "
|
|
442
|
+
"a dot. Rename the file and the line in the metadata file."
|
|
443
|
+
)
|
|
444
|
+
% {"name": station.name},
|
|
445
|
+
station.line,
|
|
446
|
+
)
|
|
447
|
+
return False
|
|
448
|
+
return True
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def similar_file(name: str, files: dict[str, bytes]) -> str | None:
|
|
452
|
+
"""An uploaded file the user may have meant: the same name in other capitals, or the
|
|
453
|
+
same name with another extension."""
|
|
454
|
+
lower = name.lower()
|
|
455
|
+
stem = PurePosixPath(lower).stem
|
|
456
|
+
for candidate in files:
|
|
457
|
+
if candidate.lower() == lower:
|
|
458
|
+
return candidate
|
|
459
|
+
for candidate in files:
|
|
460
|
+
if PurePosixPath(candidate.lower()).stem == stem:
|
|
461
|
+
return candidate
|
|
462
|
+
return None
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def short_summary(summary: dict) -> dict:
|
|
466
|
+
"""What the check page shows of a station's data (and the base period check needs)."""
|
|
467
|
+
keys = ("first_date", "last_date", "first_year", "last_year", "days")
|
|
468
|
+
out = {k: summary[k] for k in keys}
|
|
469
|
+
out["missing"] = {v["key"]: v["percent_missing"] for v in summary["variables"]}
|
|
470
|
+
return out
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def check_station(station: StationCheck, files: dict[str, bytes]) -> None:
|
|
474
|
+
if not check_name(station):
|
|
475
|
+
station.status = "invalid"
|
|
476
|
+
return
|
|
477
|
+
check_parameters(station)
|
|
478
|
+
raw = files.get(station.station_file)
|
|
479
|
+
if raw is None:
|
|
480
|
+
if station.problem_count:
|
|
481
|
+
station.status = "invalid"
|
|
482
|
+
return
|
|
483
|
+
station.status = "missing"
|
|
484
|
+
similar = similar_file(station.station_file, files)
|
|
485
|
+
if similar:
|
|
486
|
+
station.add(
|
|
487
|
+
_(
|
|
488
|
+
"No file named %(file)s was uploaded, but %(similar)s was. Names must "
|
|
489
|
+
"match the metadata file exactly, including capitals and the extension."
|
|
490
|
+
)
|
|
491
|
+
% {"file": station.station_file, "similar": similar}
|
|
492
|
+
)
|
|
493
|
+
else:
|
|
494
|
+
station.add(_("No file named %(file)s was uploaded.") % {"file": station.station_file})
|
|
495
|
+
return
|
|
496
|
+
inspection = inspect_station_bytes(raw)
|
|
497
|
+
for problem in inspection.problems:
|
|
498
|
+
station.add(problem.message, problem.line, problem.text)
|
|
499
|
+
station.problem_count += inspection.hidden_problems
|
|
500
|
+
station.warnings.extend(inspection.warnings)
|
|
501
|
+
if inspection.summary:
|
|
502
|
+
station.summary = short_summary(inspection.summary)
|
|
503
|
+
station.status = "invalid" if station.problem_count else "ready"
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def check_batch(metadata: bytes | None, files: dict[str, bytes]) -> BatchCheck:
|
|
507
|
+
"""Check a batch: the metadata file and every station it lists."""
|
|
508
|
+
result = BatchCheck()
|
|
509
|
+
if metadata is None:
|
|
510
|
+
result.add(_("Choose the batch metadata file."))
|
|
511
|
+
stations = []
|
|
512
|
+
else:
|
|
513
|
+
stations = check_metadata(metadata, result)
|
|
514
|
+
for station in stations:
|
|
515
|
+
check_station(station, files)
|
|
516
|
+
result.stations = stations
|
|
517
|
+
listed = {s.station_file for s in stations}
|
|
518
|
+
result.extra_files = sorted(name for name in files if name not in listed)
|
|
519
|
+
return result
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def ready_stations(check: dict) -> list[dict]:
|
|
523
|
+
return [s for s in check.get("stations", []) if s["status"] == "ready"]
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def metadata_text(stations: list[dict]) -> str:
|
|
527
|
+
"""A metadata file in R's format (tab-separated, R's header) for these stations."""
|
|
528
|
+
from_fields = {v: k for k, v in PARAM_FIELDS.items()}
|
|
529
|
+
lines = ["\t".join(COLUMNS)]
|
|
530
|
+
for s in stations:
|
|
531
|
+
values = {from_fields[k]: v for k, v in s["params"].items()}
|
|
532
|
+
row = [s["station_file"], _text(s["latitude"]), _text(s["longitude"])]
|
|
533
|
+
row += [_text(values[c]) for c in COLUMNS[3:]]
|
|
534
|
+
lines.append("\t".join(row))
|
|
535
|
+
return "\n".join(lines) + "\n"
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _text(value: float | int) -> str:
|
|
539
|
+
"""A number as text that reads back the same (``repr``), without a needless ``.0``."""
|
|
540
|
+
if isinstance(value, float) and value.is_integer():
|
|
541
|
+
return str(int(value))
|
|
542
|
+
return repr(value)
|