Skip to content

Commit 1fcf054

Browse files
sync embedded/arc_tz_weather from arc_tz_weather
1 parent 5903d15 commit 1fcf054

5 files changed

Lines changed: 2078 additions & 126 deletions

File tree

embedded/arc_tz_weather/index.html

Lines changed: 1862 additions & 60 deletions
Large diffs are not rendered by default.

embedded/arc_tz_weather/modules/common.py

Lines changed: 106 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"""
55

66
import math
7-
from datetime import datetime, timedelta
7+
from datetime import date, datetime, timedelta
88
from pathlib import Path
99

1010
import pandas as pd
@@ -16,21 +16,99 @@
1616
LONGITUDE = 39.18
1717
SOLAR_CONSTANT = 1361 # W/m2
1818

19-
# ── Beaufort Scale ────────────────────────────────────────────────────────────
19+
# ── Beaufort Scale (WMO definition, classified in knots) ──────────────────────
20+
# The Beaufort scale is defined in knots. All other units are derived using the
21+
# exact conversion factors below to avoid boundary errors.
22+
#
23+
# Exact conversion factors (from 1 nautical mile = 1852 m exactly):
24+
# 1 kn = 463/250 km/h (= 1.852 km/h, exact rational value)
25+
# 1 kn = 463/900 m/s (= 0.51444... m/s, exact rational value)
26+
KN_TO_KPH = 463 / 250 # km/h per knot
27+
KN_TO_MS = 463 / 900 # m/s per knot
28+
KPH_TO_KN = 250 / 463 # knots per km/h
29+
MS_TO_KN = 900 / 463 # knots per m/s
30+
31+
# Thresholds in knots (WMO integer boundaries). range_kn is [lo, hi).
2032
BEAUFORT_SCALE = {
21-
0: {"range": (0, 1), "label": "Calm"},
22-
1: {"range": (1, 6), "label": "Light air"},
23-
2: {"range": (6, 12), "label": "Light breeze"},
24-
3: {"range": (12, 20), "label": "Gentle breeze"},
25-
4: {"range": (20, 29), "label": "Moderate breeze"},
26-
5: {"range": (29, 39), "label": "Fresh breeze"},
27-
6: {"range": (39, 50), "label": "Strong breeze"},
28-
7: {"range": (50, 62), "label": "Near gale"},
29-
8: {"range": (62, 75), "label": "Gale"},
30-
9: {"range": (75, 89), "label": "Strong gale"},
31-
10: {"range": (89, 103), "label": "Storm"},
32-
11: {"range": (103, 118), "label": "Violent storm"},
33-
12: {"range": (118, 999), "label": "Hurricane"},
33+
0: {"range_kn": (0, 1), "label": "Calm"},
34+
1: {"range_kn": (1, 4), "label": "Light air"},
35+
2: {"range_kn": (4, 7), "label": "Light breeze"},
36+
3: {"range_kn": (7, 11), "label": "Gentle breeze"},
37+
4: {"range_kn": (11, 17), "label": "Moderate breeze"},
38+
5: {"range_kn": (17, 22), "label": "Fresh breeze"},
39+
6: {"range_kn": (22, 28), "label": "Strong breeze"},
40+
7: {"range_kn": (28, 34), "label": "Near gale"},
41+
8: {"range_kn": (34, 41), "label": "Gale"},
42+
9: {"range_kn": (41, 48), "label": "Strong gale"},
43+
10: {"range_kn": (48, 56), "label": "Storm"},
44+
11: {"range_kn": (56, 64), "label": "Violent storm"},
45+
12: {"range_kn": (64, 999), "label": "Hurricane force"},
46+
}
47+
48+
# ── Wind Classification Systems ─────────────────────────────────────────────
49+
# All systems defined in their original published units.
50+
# Use the conversion constants above to convert to the working unit at runtime.
51+
#
52+
# native_unit: 'kn' = knots, 'ms' = m/s, 'computed' = derived from dataset.
53+
# bands: list of {label, lo, hi} where hi=None means no upper limit.
54+
WIND_CLASSIFICATIONS = {
55+
"beaufort": {
56+
"label": "Beaufort",
57+
"native_unit": "kn",
58+
"source": "WMO/Beaufort 1805",
59+
"notes": "Sea-level scale; Bf 9-12 merged into Severe+ for pedestrian relevance. Force numbers available as metadata.",
60+
"bands": [
61+
{"label": "Calm", "lo": 0, "hi": 1, "force": 0},
62+
{"label": "Light Air", "lo": 1, "hi": 4, "force": 1},
63+
{"label": "Light Breeze", "lo": 4, "hi": 7, "force": 2},
64+
{"label": "Gentle Breeze", "lo": 7, "hi": 11, "force": 3},
65+
{"label": "Moderate Breeze", "lo": 11, "hi": 17, "force": 4},
66+
{"label": "Fresh Breeze", "lo": 17, "hi": 22, "force": 5},
67+
{"label": "Strong Breeze", "lo": 22, "hi": 28, "force": 6},
68+
{"label": "Near Gale", "lo": 28, "hi": 34, "force": 7},
69+
{"label": "Gale", "lo": 34, "hi": 41, "force": 8},
70+
{"label": "Severe+", "lo": 41, "hi": None, "force": "9+"},
71+
],
72+
},
73+
"lawson": {
74+
"label": "Lawson 2001",
75+
"native_unit": "ms",
76+
"source": "Lawson T.V., 2001, pedestrian comfort criteria, assessed at 1.5m height",
77+
"notes": "Upper-bound thresholds. Use this version, not LDDC.",
78+
"bands": [
79+
{"label": "Sitting", "lo": 0, "hi": 4},
80+
{"label": "Standing", "lo": 4, "hi": 6},
81+
{"label": "Strolling", "lo": 6, "hi": 8},
82+
{"label": "Business Walking", "lo": 8, "hi": 10},
83+
{"label": "Uncomfortable", "lo": 10, "hi": None},
84+
],
85+
},
86+
"davenport": {
87+
"label": "Davenport",
88+
"native_unit": "ms",
89+
"source": "Davenport A.G., 1975, first published pedestrian wind comfort criterion, exceedance probability 1.5%",
90+
"notes": "Upper-bound thresholds.",
91+
"bands": [
92+
{"label": "Long Sitting", "lo": 0, "hi": 3.6},
93+
{"label": "Short Sitting", "lo": 3.6, "hi": 5.3},
94+
{"label": "Walking Quietly", "lo": 5.3, "hi": 7.6},
95+
{"label": "Walking Fast", "lo": 7.6, "hi": 9.8},
96+
{"label": "Uncomfortable", "lo": 9.8, "hi": None},
97+
],
98+
},
99+
"percentile": {
100+
"label": "Percentiles",
101+
"native_unit": "computed",
102+
"source": "WMO climatological quintile practice",
103+
"notes": "Boundaries computed at runtime from the full available dataset. Quintile-based: P20, P50, P80, P95.",
104+
"bands": [
105+
{"label": "Calm", "percentile_hi": 20},
106+
{"label": "Light", "percentile_lo": 20, "percentile_hi": 50},
107+
{"label": "Moderate", "percentile_lo": 50, "percentile_hi": 80},
108+
{"label": "Strong", "percentile_lo": 80, "percentile_hi": 95},
109+
{"label": "Exceptional", "percentile_lo": 95},
110+
],
111+
},
34112
}
35113

36114
# ── 16-point Compass ─────────────────────────────────────────────────────────
@@ -228,10 +306,15 @@ def compass_bin(degrees, n_points=16):
228306

229307

230308
def beaufort_number(speed_kph):
231-
"""Return the Beaufort number for a given wind speed in km/h."""
309+
"""Return the Beaufort number for a given wind speed in km/h.
310+
311+
Classification is performed in knots (WMO definition) to avoid
312+
boundary errors from rounded secondary-unit thresholds.
313+
"""
314+
speed_kn = speed_kph * KPH_TO_KN
232315
for num, info in BEAUFORT_SCALE.items():
233-
lo, hi = info["range"]
234-
if lo <= speed_kph < hi:
316+
lo, hi = info["range_kn"]
317+
if lo <= speed_kn < hi:
235318
return num
236319
return 12
237320

@@ -338,8 +421,11 @@ def build_available_periods(df):
338421
key = (d.year, s_idx)
339422
if key not in seen_seasons:
340423
seen_seasons.add(key)
424+
_MONTH_ABBR = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
425+
m_first = _MONTH_ABBR[SEASONS[s_idx]['months'][0] - 1]
426+
m_last = _MONTH_ABBR[SEASONS[s_idx]['months'][-1] - 1]
341427
seasons.append({
342-
"label": f"{s_name} ({SEASONS[s_idx]['months'][0]}-{SEASONS[s_idx]['months'][-1]}) {d.year}",
428+
"label": f"{s_name} ({m_first}\u2013{m_last}) {d.year}",
343429
"year": d.year, "season": s_idx
344430
})
345431

@@ -352,7 +438,7 @@ def build_available_periods(df):
352438
if key not in seen_weeks:
353439
seen_weeks.add(key)
354440
weeks.append({
355-
"label": f"Week {iso_week}, {iso_year}",
441+
"label": "W/s " + date.fromisocalendar(iso_year, iso_week, 1).strftime("%d/%m/%y"),
356442
"year": iso_year, "week": iso_week
357443
})
358444

embedded/arc_tz_weather/modules/precipitation.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import numpy as np
88

99
from .common import (
10-
RAIN_INTENSITY_COLORS, RAIN_DAILY_COLORS,
10+
RAIN_INTENSITY_COLORS, RAIN_DAILY_COLORS, TIMEZONE,
1111
detect_precip_resets, to_eat_ms, get_season_boundaries, compass_bin,
1212
)
1313

@@ -89,17 +89,21 @@ def _compute_daily_rainfall(pdf):
8989
total = group["precip_incr"].sum()
9090
daily.append({
9191
"date": date,
92-
"date_ms": int(pd.Timestamp(date).timestamp() * 1000),
92+
"date_ms": int(pd.Timestamp(date).tz_localize(TIMEZONE).timestamp() * 1000),
9393
"total_mm": round(float(total), 2),
9494
})
9595
return daily
9696

9797

98-
def _detect_rain_events(pdf, gap_tolerance_min=15):
98+
def _detect_rain_events(pdf, gap_tolerance_min=15, min_depth_mm=0.5):
9999
"""Detect rain events by grouping consecutive readings with rate > 0.
100100
101-
Allows gaps of up to gap_tolerance_min minutes.
101+
Allows gaps of up to gap_tolerance_min minutes. Events with total depth
102+
below min_depth_mm (WMO trace threshold) are excluded.
102103
"""
104+
# Compute median sampling interval for minimum duration fallback
105+
median_interval_min = pdf["timestamp"].diff().dt.total_seconds().median() / 60
106+
103107
raining = pdf["precip_rate_mmh"] > 0
104108
events = []
105109
in_event = False
@@ -127,19 +131,23 @@ def _detect_rain_events(pdf, gap_tolerance_min=15):
127131
in_event = False
128132
event_data = pdf.iloc[event_rows[0]:last_rain_idx + 1]
129133
if len(event_data) > 0:
130-
events.append(_summarize_event(event_data, pdf))
134+
summary = _summarize_event(event_data, pdf, median_interval_min)
135+
if summary["total_mm"] >= min_depth_mm:
136+
events.append(summary)
131137
event_rows = []
132138

133139
# Handle event at end of data
134140
if in_event and event_rows:
135141
event_data = pdf.iloc[event_rows[0]:last_rain_idx + 1]
136142
if len(event_data) > 0:
137-
events.append(_summarize_event(event_data, pdf))
143+
summary = _summarize_event(event_data, pdf, median_interval_min)
144+
if summary["total_mm"] >= min_depth_mm:
145+
events.append(summary)
138146

139147
return events
140148

141149

142-
def _summarize_event(event_data, full_df):
150+
def _summarize_event(event_data, full_df, min_duration_min=5):
143151
"""Summarize a single rain event."""
144152
start = event_data["timestamp"].iloc[0]
145153
end = event_data["timestamp"].iloc[-1]

embedded/arc_tz_weather/modules/solar.py

Lines changed: 36 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import numpy as np
1010

1111
from .common import (
12-
SOLAR_COLORS, LATITUDE, LONGITUDE, to_eat_ms,
12+
SOLAR_COLORS, LATITUDE, LONGITUDE, TIMEZONE, to_eat_ms,
1313
extraterrestrial_radiation, get_season_boundaries,
1414
)
1515

@@ -98,7 +98,7 @@ def _compute_daily_insolation(sdf):
9898
doy = pd.Timestamp(date).timetuple().tm_yday
9999
daily.append({
100100
"date": date,
101-
"date_ms": int(pd.Timestamp(date).timestamp() * 1000),
101+
"date_ms": int(pd.Timestamp(date).tz_localize(TIMEZONE).timestamp() * 1000),
102102
"insolation_kwh": round(kwh, 3),
103103
"day_of_year": doy,
104104
})
@@ -355,61 +355,75 @@ def _build_solar_distribution(sdf):
355355

356356

357357
def _build_clearness_index(daily_kt):
358-
"""Build clearness index scatter plot."""
358+
"""Build clearness index scatter plot.
359+
360+
Thresholds are calibrated for a humid tropical coastal site (Mkuranga, ~7S).
361+
Standard temperate-climate thresholds (clear > 0.65, overcast < 0.35) are
362+
inappropriate here: even on a genuinely clear day, high precipitable water
363+
vapour and marine aerosols from the Indian Ocean suppress the clear-sky Kt
364+
ceiling to approximately 0.55-0.65. Using temperate thresholds causes clear
365+
days to be systematically mis-classified as partly cloudy.
366+
367+
References:
368+
- Saunier, Reddy & Kumar (1987), Solar Energy 38(3): 169-177: generalised
369+
Liu-Jordan CDCs are not suitable for tropical locations; Kmax must be
370+
derived from local data.
371+
- Udo (2000), Solar Energy 69(1): 45-53: tropical Nigerian site (~7N, same
372+
latitude band) confirms Liu-Jordan inapplicability; highest recorded
373+
clear-sky Kt was 0.64 after rainfall cleared aerosols.
374+
- Diabate, Blanc & Wald (2004), Solar Energy 76(6): 733-744: Tanzania's
375+
coastal zone falls in a humid low-Kt climate class with monthly mean
376+
Kt typically 0.45-0.55 during dry-season months.
377+
"""
359378
if daily_kt.empty:
360379
return {"id": "clearness-index", "title": "Clearness Index",
361380
"title_sw": "Fahirisi ya Uwazi", "data": [], "layout": {}}
362381

363382
dates_ms = daily_kt["date_ms"].tolist()
364383
kt_vals = daily_kt["kt"].tolist()
365384

366-
# Color by category
367-
colors = []
368-
for kt in kt_vals:
369-
if kt > 0.65:
370-
colors.append("#2ca02c") # clear
371-
elif kt > 0.35:
372-
colors.append("#ffbf00") # partly cloudy
373-
else:
374-
colors.append("#4575b4") # overcast
385+
# Thresholds adjusted for humid tropical coastal climate (see docstring).
386+
# Clear: Kt > 0.55, Partly cloudy: 0.25-0.55, Overcast: Kt <= 0.25
387+
KT_CLEAR = 0.55
388+
KT_OVERCAST = 0.25
375389

376390
traces = [{
377391
"type": "scatter",
378392
"mode": "markers",
379393
"name": "Clearness Index (Kt)",
380394
"x_ms": dates_ms,
381395
"y": kt_vals,
382-
"marker": {"color": colors, "size": 8},
396+
"marker": {"color": "#000000", "size": 8},
383397
}]
384398

385399
layout = {
386400
"yaxis": {"title": "Clearness Index (Kt)", "range": [0, 1]},
387401
"xaxis": {"title": "Date (EAT)"},
388402
"shapes": [
389403
{"type": "rect", "x0": 0, "x1": 1, "xref": "paper",
390-
"y0": 0.65, "y1": 1, "fillcolor": "rgba(44,160,44,0.1)",
404+
"y0": KT_CLEAR, "y1": 1, "fillcolor": "rgba(44,160,44,0.1)",
391405
"line": {"width": 0}},
392406
{"type": "rect", "x0": 0, "x1": 1, "xref": "paper",
393-
"y0": 0.35, "y1": 0.65, "fillcolor": "rgba(255,191,0,0.1)",
407+
"y0": KT_OVERCAST, "y1": KT_CLEAR, "fillcolor": "rgba(255,191,0,0.1)",
394408
"line": {"width": 0}},
395409
{"type": "rect", "x0": 0, "x1": 1, "xref": "paper",
396-
"y0": 0, "y1": 0.35, "fillcolor": "rgba(69,117,180,0.1)",
410+
"y0": 0, "y1": KT_OVERCAST, "fillcolor": "rgba(69,117,180,0.1)",
397411
"line": {"width": 0}},
398412
],
399413
"annotations": [
400-
{"x": 1.02, "xref": "paper", "y": 0.82, "text": "Clear",
414+
{"x": 1.02, "xref": "paper", "y": 0.77, "text": "Clear",
401415
"showarrow": False, "font": {"size": 10, "color": "#2ca02c"}},
402-
{"x": 1.02, "xref": "paper", "y": 0.5, "text": "Partly Cloudy",
416+
{"x": 1.02, "xref": "paper", "y": 0.4, "text": "Partly Cloudy",
403417
"showarrow": False, "font": {"size": 10, "color": "#b8860b"}},
404-
{"x": 1.02, "xref": "paper", "y": 0.17, "text": "Overcast",
418+
{"x": 1.02, "xref": "paper", "y": 0.12, "text": "Overcast",
405419
"showarrow": False, "font": {"size": 10, "color": "#4575b4"}},
406420
],
407421
}
408422

409423
# Sky condition distribution
410-
clear_pct = round((daily_kt["kt"] > 0.65).sum() / len(daily_kt) * 100, 1)
411-
partly_pct = round(((daily_kt["kt"] > 0.35) & (daily_kt["kt"] <= 0.65)).sum() / len(daily_kt) * 100, 1)
412-
overcast_pct = round((daily_kt["kt"] <= 0.35).sum() / len(daily_kt) * 100, 1)
424+
clear_pct = round((daily_kt["kt"] > KT_CLEAR).sum() / len(daily_kt) * 100, 1)
425+
partly_pct = round(((daily_kt["kt"] > KT_OVERCAST) & (daily_kt["kt"] <= KT_CLEAR)).sum() / len(daily_kt) * 100, 1)
426+
overcast_pct = round((daily_kt["kt"] <= KT_OVERCAST).sum() / len(daily_kt) * 100, 1)
413427

414428
return {
415429
"id": "clearness-index",
@@ -445,19 +459,6 @@ def _build_peak_solar_hours(daily_df):
445459
layout = {
446460
"yaxis": {"title": "Peak Solar Hours"},
447461
"xaxis": {"title": "Date (EAT)"},
448-
"shapes": [{
449-
"type": "line",
450-
"x0": 0, "x1": 1, "xref": "paper",
451-
"y0": 5.25, "y1": 5.25,
452-
"line": {"color": "red", "width": 1, "dash": "dash"},
453-
}],
454-
"annotations": [{
455-
"x": 1, "xref": "paper", "y": 5.25,
456-
"text": "Coastal Tanzania typical (5-5.5 PSH)",
457-
"showarrow": False,
458-
"xanchor": "right",
459-
"font": {"size": 10, "color": "red"},
460-
}],
461462
}
462463

463464
return {

0 commit comments

Comments
 (0)