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.
Files changed (230) hide show
  1. climpact_web/__init__.py +7 -0
  2. climpact_web/__main__.py +7 -0
  3. climpact_web/batch/__init__.py +0 -0
  4. climpact_web/batch/apps.py +13 -0
  5. climpact_web/batch/check.py +542 -0
  6. climpact_web/batch/forms.py +179 -0
  7. climpact_web/batch/migrations/0001_initial.py +62 -0
  8. climpact_web/batch/migrations/__init__.py +0 -0
  9. climpact_web/batch/models.py +126 -0
  10. climpact_web/batch/retention.py +40 -0
  11. climpact_web/batch/sample_data/climpact.sample.batch.metadata.txt +3 -0
  12. climpact_web/batch/templates/batch/_options.html +26 -0
  13. climpact_web/batch/templates/batch/_progress.html +126 -0
  14. climpact_web/batch/templates/batch/_retention.html +8 -0
  15. climpact_web/batch/templates/batch/check.html +155 -0
  16. climpact_web/batch/templates/batch/results.html +33 -0
  17. climpact_web/batch/templates/batch/start.html +84 -0
  18. climpact_web/batch/urls.py +19 -0
  19. climpact_web/batch/views.py +585 -0
  20. climpact_web/charts/__init__.py +0 -0
  21. climpact_web/charts/apps.py +7 -0
  22. climpact_web/charts/data.py +506 -0
  23. climpact_web/charts/sample_outputs/sydney/indices/sydney_3month_spi_MON.csv +968 -0
  24. climpact_web/charts/sample_outputs/sydney/indices/sydney_prcptot_ANN.csv +88 -0
  25. climpact_web/charts/sample_outputs/sydney/indices/sydney_txx_ANN.csv +88 -0
  26. climpact_web/charts/sample_outputs/sydney/indices/sydney_txx_MON.csv +968 -0
  27. climpact_web/charts/sample_outputs/sydney/trend/sydney_trend.csv +24 -0
  28. climpact_web/charts/templatetags/__init__.py +0 -0
  29. climpact_web/charts/templatetags/charts.py +56 -0
  30. climpact_web/charts/urls.py +14 -0
  31. climpact_web/charts/views.py +255 -0
  32. climpact_web/cli.py +308 -0
  33. climpact_web/core/__init__.py +0 -0
  34. climpact_web/core/apps.py +9 -0
  35. climpact_web/core/context_processors.py +14 -0
  36. climpact_web/core/forms.py +47 -0
  37. climpact_web/core/management/__init__.py +0 -0
  38. climpact_web/core/management/commands/__init__.py +0 -0
  39. climpact_web/core/management/commands/design_snapshots.py +293 -0
  40. climpact_web/core/navigation.py +161 -0
  41. climpact_web/core/static/climpact_web/chart-palette.json +85 -0
  42. climpact_web/core/static/climpact_web/css/climpact.css +2 -0
  43. climpact_web/core/static/climpact_web/icons/favicon.svg +1 -0
  44. climpact_web/core/static/climpact_web/icons/icons.svg +46 -0
  45. climpact_web/core/static/climpact_web/js/charts.js +979 -0
  46. climpact_web/core/static/climpact_web/js/gridded.js +21 -0
  47. climpact_web/core/static/climpact_web/js/qc.js +108 -0
  48. climpact_web/core/static/climpact_web/js/results.js +78 -0
  49. climpact_web/core/static/climpact_web/js/sector.js +18 -0
  50. climpact_web/core/static/climpact_web/js/tour.js +191 -0
  51. climpact_web/core/static/climpact_web/js/ui.js +353 -0
  52. climpact_web/core/static/climpact_web/vendor/htmx/README.md +14 -0
  53. climpact_web/core/static/climpact_web/vendor/htmx/htmx.min.js +1 -0
  54. climpact_web/core/static/climpact_web/vendor/uplot/LICENSE +21 -0
  55. climpact_web/core/static/climpact_web/vendor/uplot/README.md +23 -0
  56. climpact_web/core/static/climpact_web/vendor/uplot/uPlot.iife.min.js +2 -0
  57. climpact_web/core/static/climpact_web/vendor/uplot/uPlot.min.css +1 -0
  58. climpact_web/core/storage.py +70 -0
  59. climpact_web/core/templates/404.html +7 -0
  60. climpact_web/core/templates/413.html +9 -0
  61. climpact_web/core/templates/500.html +20 -0
  62. climpact_web/core/templates/base.html +79 -0
  63. climpact_web/core/templates/components/alert.html +15 -0
  64. climpact_web/core/templates/components/badge.html +2 -0
  65. climpact_web/core/templates/components/button.html +9 -0
  66. climpact_web/core/templates/components/card.html +10 -0
  67. climpact_web/core/templates/components/chart_frame.html +62 -0
  68. climpact_web/core/templates/components/dialog.html +20 -0
  69. climpact_web/core/templates/components/disclosure.html +5 -0
  70. climpact_web/core/templates/components/dropzone.html +15 -0
  71. climpact_web/core/templates/components/empty_state.html +7 -0
  72. climpact_web/core/templates/components/field.html +33 -0
  73. climpact_web/core/templates/components/field_errors.html +1 -0
  74. climpact_web/core/templates/components/form_errors.html +16 -0
  75. climpact_web/core/templates/components/messages.html +7 -0
  76. climpact_web/core/templates/components/progress.html +9 -0
  77. climpact_web/core/templates/components/skeleton.html +10 -0
  78. climpact_web/core/templates/components/stages.html +12 -0
  79. climpact_web/core/templates/components/stat.html +2 -0
  80. climpact_web/core/templates/components/steps.html +20 -0
  81. climpact_web/core/templates/components/summary_list.html +4 -0
  82. climpact_web/core/templates/components/table.html +18 -0
  83. climpact_web/core/templates/components/tabs.html +17 -0
  84. climpact_web/core/templates/components/theme_toggle.html +6 -0
  85. climpact_web/core/templates/components/toasts.html +12 -0
  86. climpact_web/core/templates/core/home.html +117 -0
  87. climpact_web/core/templates/core/language_switcher.html +12 -0
  88. climpact_web/core/templates/core/patterns.html +291 -0
  89. climpact_web/core/templates/core/patterns_table.html +1 -0
  90. climpact_web/core/templatetags/__init__.py +0 -0
  91. climpact_web/core/templatetags/ui.py +63 -0
  92. climpact_web/core/tokens.py +90 -0
  93. climpact_web/core/urls.py +11 -0
  94. climpact_web/core/views.py +189 -0
  95. climpact_web/gridded/__init__.py +0 -0
  96. climpact_web/gridded/apps.py +13 -0
  97. climpact_web/gridded/forms.py +499 -0
  98. climpact_web/gridded/inspection.py +412 -0
  99. climpact_web/gridded/maps.py +444 -0
  100. climpact_web/gridded/migrations/0001_initial.py +73 -0
  101. climpact_web/gridded/migrations/__init__.py +0 -0
  102. climpact_web/gridded/models.py +109 -0
  103. climpact_web/gridded/retention.py +44 -0
  104. climpact_web/gridded/sample_data/climpact.sampledata.gridded.3x4.1991-2010.nc +0 -0
  105. climpact_web/gridded/templates/gridded/_browser.html +23 -0
  106. climpact_web/gridded/templates/gridded/_checks.html +29 -0
  107. climpact_web/gridded/templates/gridded/_chooser.html +27 -0
  108. climpact_web/gridded/templates/gridded/_explore.html +131 -0
  109. climpact_web/gridded/templates/gridded/_progress.html +93 -0
  110. climpact_web/gridded/templates/gridded/_retention.html +1 -0
  111. climpact_web/gridded/templates/gridded/_series_table.html +8 -0
  112. climpact_web/gridded/templates/gridded/results.html +27 -0
  113. climpact_web/gridded/templates/gridded/setup.html +196 -0
  114. climpact_web/gridded/templates/gridded/start.html +73 -0
  115. climpact_web/gridded/urls.py +23 -0
  116. climpact_web/gridded/views.py +892 -0
  117. climpact_web/help/__init__.py +0 -0
  118. climpact_web/help/apps.py +8 -0
  119. climpact_web/help/guides/en/accessibility.md +89 -0
  120. climpact_web/help/guides/en/base-period.md +70 -0
  121. climpact_web/help/guides/en/batch.md +93 -0
  122. climpact_web/help/guides/en/faq.md +72 -0
  123. climpact_web/help/guides/en/gridded.md +86 -0
  124. climpact_web/help/guides/en/indices.md +137 -0
  125. climpact_web/help/guides/en/quality-control.md +113 -0
  126. climpact_web/help/guides/en/sector.md +77 -0
  127. climpact_web/help/guides/en/station-file.md +89 -0
  128. climpact_web/help/guides/en/trends.md +67 -0
  129. climpact_web/help/guides.py +288 -0
  130. climpact_web/help/index_messages.py +145 -0
  131. climpact_web/help/indices.py +310 -0
  132. climpact_web/help/templates/help/_results.html +20 -0
  133. climpact_web/help/templates/help/_search_form.html +15 -0
  134. climpact_web/help/templates/help/_topic.html +11 -0
  135. climpact_web/help/templates/help/_tour.html +16 -0
  136. climpact_web/help/templates/help/guide.html +35 -0
  137. climpact_web/help/templates/help/index.html +67 -0
  138. climpact_web/help/templates/help/search.html +11 -0
  139. climpact_web/help/templates/help/topics.html +31 -0
  140. climpact_web/help/templatetags/__init__.py +0 -0
  141. climpact_web/help/templatetags/help.py +40 -0
  142. climpact_web/help/topics.py +795 -0
  143. climpact_web/help/tour.py +58 -0
  144. climpact_web/help/urls.py +12 -0
  145. climpact_web/help/views.py +67 -0
  146. climpact_web/jobs/__init__.py +0 -0
  147. climpact_web/jobs/apps.py +10 -0
  148. climpact_web/jobs/context_processors.py +7 -0
  149. climpact_web/jobs/forms.py +13 -0
  150. climpact_web/jobs/management/__init__.py +0 -0
  151. climpact_web/jobs/management/commands/__init__.py +0 -0
  152. climpact_web/jobs/management/commands/purgejobs.py +22 -0
  153. climpact_web/jobs/management/commands/runworker.py +41 -0
  154. climpact_web/jobs/migrations/0001_initial.py +75 -0
  155. climpact_web/jobs/migrations/__init__.py +0 -0
  156. climpact_web/jobs/models.py +170 -0
  157. climpact_web/jobs/registry.py +76 -0
  158. climpact_web/jobs/retention.py +99 -0
  159. climpact_web/jobs/runners.py +587 -0
  160. climpact_web/jobs/stages.py +41 -0
  161. climpact_web/jobs/storage.py +76 -0
  162. climpact_web/jobs/templates/jobs/_progress.html +83 -0
  163. climpact_web/jobs/templates/jobs/_status.html +2 -0
  164. climpact_web/jobs/templates/jobs/job_detail.html +20 -0
  165. climpact_web/jobs/templates/jobs/job_list.html +35 -0
  166. climpact_web/jobs/urls.py +14 -0
  167. climpact_web/jobs/views.py +177 -0
  168. climpact_web/jobs/worker.py +402 -0
  169. climpact_web/locale/en/LC_MESSAGES/django.po +7828 -0
  170. climpact_web/middleware.py +69 -0
  171. climpact_web/settings.py +264 -0
  172. climpact_web/stations/__init__.py +0 -0
  173. climpact_web/stations/apps.py +13 -0
  174. climpact_web/stations/downloads.py +184 -0
  175. climpact_web/stations/forms.py +295 -0
  176. climpact_web/stations/forms_indices.py +397 -0
  177. climpact_web/stations/migrations/0001_initial.py +98 -0
  178. climpact_web/stations/migrations/0002_batch_origin.py +30 -0
  179. climpact_web/stations/migrations/0003_qc_params.py +17 -0
  180. climpact_web/stations/migrations/__init__.py +0 -0
  181. climpact_web/stations/models.py +180 -0
  182. climpact_web/stations/preview.py +98 -0
  183. climpact_web/stations/qc_plots.py +338 -0
  184. climpact_web/stations/qc_review.py +856 -0
  185. climpact_web/stations/results.py +793 -0
  186. climpact_web/stations/retention.py +57 -0
  187. climpact_web/stations/sample_data/sydney_observatory_hill_1936-2015.txt.gz +0 -0
  188. climpact_web/stations/sample_data/wheat_yield_nsw_1922-1999.csv +79 -0
  189. climpact_web/stations/sector_file.py +345 -0
  190. climpact_web/stations/sector_plots.py +374 -0
  191. climpact_web/stations/station_file.py +406 -0
  192. climpact_web/stations/templates/stations/_download_link.html +1 -0
  193. climpact_web/stations/templates/stations/_indices_unit.html +49 -0
  194. climpact_web/stations/templates/stations/_problems.html +17 -0
  195. climpact_web/stations/templates/stations/_qc_box_plot.html +27 -0
  196. climpact_web/stations/templates/stations/_qc_check.html +14 -0
  197. climpact_web/stations/templates/stations/_qc_distributions.html +77 -0
  198. climpact_web/stations/templates/stations/_qc_form.html +20 -0
  199. climpact_web/stations/templates/stations/_qc_missing.html +40 -0
  200. climpact_web/stations/templates/stations/_qc_progress.html +26 -0
  201. climpact_web/stations/templates/stations/_qc_summary.html +54 -0
  202. climpact_web/stations/templates/stations/_qc_table.html +34 -0
  203. climpact_web/stations/templates/stations/_results_progress.html +67 -0
  204. climpact_web/stations/templates/stations/_retention.html +16 -0
  205. climpact_web/stations/templates/stations/_sector_progress.html +40 -0
  206. climpact_web/stations/templates/stations/_sector_results.html +94 -0
  207. climpact_web/stations/templates/stations/_spark.html +1 -0
  208. climpact_web/stations/templates/stations/_summary.html +105 -0
  209. climpact_web/stations/templates/stations/_unit.html +33 -0
  210. climpact_web/stations/templates/stations/downloads.html +104 -0
  211. climpact_web/stations/templates/stations/indices.html +108 -0
  212. climpact_web/stations/templates/stations/load.html +62 -0
  213. climpact_web/stations/templates/stations/qc.html +133 -0
  214. climpact_web/stations/templates/stations/result.html +118 -0
  215. climpact_web/stations/templates/stations/results.html +143 -0
  216. climpact_web/stations/templates/stations/results_progress.html +24 -0
  217. climpact_web/stations/templates/stations/sector.html +91 -0
  218. climpact_web/stations/templates/stations/upload.html +73 -0
  219. climpact_web/stations/urls.py +51 -0
  220. climpact_web/stations/views.py +530 -0
  221. climpact_web/stations/views_downloads.py +53 -0
  222. climpact_web/stations/views_indices.py +545 -0
  223. climpact_web/stations/views_sector.py +219 -0
  224. climpact_web/urls.py +14 -0
  225. climpact_web/wsgi.py +9 -0
  226. climpact_web-0.1.0.dist-info/METADATA +97 -0
  227. climpact_web-0.1.0.dist-info/RECORD +230 -0
  228. climpact_web-0.1.0.dist-info/WHEEL +4 -0
  229. climpact_web-0.1.0.dist-info/entry_points.txt +2 -0
  230. climpact_web-0.1.0.dist-info/licenses/LICENSE +675 -0
@@ -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"
@@ -0,0 +1,7 @@
1
+ """``python -m climpact_web``: the same as the ``climpact-web`` command."""
2
+
3
+ import sys
4
+
5
+ from climpact_web.cli import main
6
+
7
+ sys.exit(main())
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)