first_commit

This commit is contained in:
rpotter6298
2026-04-25 20:35:25 +02:00
commit 9d15860f0b
22 changed files with 2254 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
"""Gibil intelligence package for Astrape."""
+2
View File
@@ -0,0 +1,2 @@
"""Core classes used by Gibil."""
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
from datetime import datetime, timezone
from gibil.classes.models import Decision, PowerStage, Snapshot
class GibilAgent:
"""Stateful decision engine for Astrape."""
def __init__(self) -> None:
self.surplus_latch_set = False
self.previous_stage: PowerStage | None = None
def decide(self, snapshot: Snapshot) -> Decision:
reasons: list[str] = []
self._update_surplus_latch(snapshot, reasons)
if self.surplus_latch_set:
stage = PowerStage.SURPLUS
reasons.append("surplus latch is set")
elif snapshot.cheap_window_active:
stage = PowerStage.CHEAP_GRID
reasons.append("cheap grid window is active")
elif self._should_conserve(snapshot):
stage = PowerStage.CONSERVE
reasons.append("battery is low and there is no useful solar surplus")
else:
stage = PowerStage.STANDARD
reasons.append("no surplus, cheap window, or conserve condition is active")
if self.previous_stage != stage:
previous = self.previous_stage.value if self.previous_stage else "none"
reasons.append(f"stage changed from {previous} to {stage.value}")
self.previous_stage = stage
return Decision(
created_at=datetime.now(timezone.utc),
stage=stage,
reasons=reasons,
confidence=self._confidence(snapshot),
)
def _update_surplus_latch(
self, snapshot: Snapshot, reasons: list[str]
) -> None:
if snapshot.battery_soc_pct is None or snapshot.solar_power_w is None:
return
home_power_w = snapshot.home_power_w or 0
has_surplus = snapshot.solar_power_w > home_power_w
if not self.surplus_latch_set:
if snapshot.battery_soc_pct >= 95 and has_surplus:
self.surplus_latch_set = True
reasons.append("surplus latch set: battery >= 95% and solar exceeds load")
return
if snapshot.battery_soc_pct < 80 or not has_surplus:
self.surplus_latch_set = False
reasons.append("surplus latch cleared: battery < 80% or surplus ended")
def _should_conserve(self, snapshot: Snapshot) -> bool:
if snapshot.battery_soc_pct is None:
return False
solar_power_w = snapshot.solar_power_w or 0
home_power_w = snapshot.home_power_w or 0
return snapshot.battery_soc_pct < 25 and solar_power_w < home_power_w
def _confidence(self, snapshot: Snapshot) -> float:
expected_inputs = [
snapshot.solar_power_w,
snapshot.home_power_w,
snapshot.battery_soc_pct,
]
present = sum(value is not None for value in expected_inputs)
return present / len(expected_inputs)
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
from os import environ
from pathlib import Path
class EnvLoader:
"""Loads Astrape env files without overriding process environment."""
def __init__(self, env_dir: Path | None = None) -> None:
if env_dir is None:
env_dir = Path(__file__).resolve().parents[2] / "env"
self.env_dir = env_dir
def load(self) -> None:
if not self.env_dir.exists():
return
for path in sorted(self.env_dir.glob("*.env")):
self._load_file(path)
def _load_file(self, path: Path) -> None:
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = self._clean_value(value.strip())
if key and key not in environ:
environ[key] = value
def _clean_value(self, value: str) -> str:
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
return value[1:-1]
return value
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any
class ObservationQuality(str, Enum):
OK = "ok"
STALE = "stale"
ESTIMATED = "estimated"
MISSING = "missing"
ERROR = "error"
class PowerStage(str, Enum):
ALWAYS = "always"
SURPLUS = "surplus"
CHEAP_GRID = "cheap_grid"
STANDARD = "standard"
CONSERVE = "conserve"
@dataclass(frozen=True)
class Observation:
source: str
metric: str
value: int | float | str | bool | None
unit: str = "none"
quality: ObservationQuality = ObservationQuality.OK
observed_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
received_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class Snapshot:
created_at: datetime
solar_power_w: float | None = None
home_power_w: float | None = None
battery_soc_pct: float | None = None
grid_import_w: float | None = None
grid_export_w: float | None = None
cheap_window_active: bool = False
input_quality: dict[str, ObservationQuality] = field(default_factory=dict)
@dataclass(frozen=True)
class Decision:
created_at: datetime
stage: PowerStage
reasons: list[str]
confidence: float
@dataclass(frozen=True)
class WeatherForecastPoint:
issued_at: datetime
target_at: datetime
horizon_hours: int
temperature_c: float | None
shortwave_radiation_w_m2: float | None
cloud_cover_pct: float | None = None
source: str = "open_meteo"
@dataclass(frozen=True)
class WeatherForecastRun:
issued_at: datetime
source: str
latitude: float
longitude: float
points: list[WeatherForecastPoint]
@dataclass(frozen=True)
class WeatherResolvedTruth:
resolved_at: datetime
temperature_c: float | None
shortwave_radiation_w_m2: float | None
source: str
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
from datetime import datetime, timezone
from gibil.classes.models import Observation, ObservationQuality, Snapshot
class SnapshotBuilder:
"""Builds Gibil's decision input from the latest observations."""
def build(self, observations: list[Observation]) -> Snapshot:
latest = self._latest_by_metric(observations)
return Snapshot(
created_at=datetime.now(timezone.utc),
solar_power_w=self._number(latest, "solar_power_w"),
home_power_w=self._number(latest, "home_power_w"),
battery_soc_pct=self._number(latest, "battery_soc_pct"),
grid_import_w=self._number(latest, "grid_import_w"),
grid_export_w=self._number(latest, "grid_export_w"),
cheap_window_active=self._boolean(latest, "cheap_window_active"),
input_quality={
metric: observation.quality for metric, observation in latest.items()
},
)
def _latest_by_metric(
self, observations: list[Observation]
) -> dict[str, Observation]:
latest: dict[str, Observation] = {}
for observation in observations:
existing = latest.get(observation.metric)
if existing is None or observation.observed_at > existing.observed_at:
latest[observation.metric] = observation
return latest
def _number(
self, observations: dict[str, Observation], metric: str
) -> float | None:
observation = observations.get(metric)
if observation is None or observation.quality != ObservationQuality.OK:
return None
if isinstance(observation.value, bool):
return None
if isinstance(observation.value, int | float):
return float(observation.value)
return None
def _boolean(self, observations: dict[str, Observation], metric: str) -> bool:
observation = observations.get(metric)
if observation is None or observation.quality != ObservationQuality.OK:
return False
if isinstance(observation.value, bool):
return observation.value
return False
+251
View File
@@ -0,0 +1,251 @@
from __future__ import annotations
from datetime import date, datetime, timezone
from typing import Any
from urllib.parse import urlencode
from urllib.request import urlopen
import json
from gibil.classes.models import (
WeatherForecastPoint,
WeatherForecastRun,
WeatherResolvedTruth,
)
class OpenMeteoClient:
"""Fetches external weather forecasts from Open-Meteo."""
base_url = "https://api.open-meteo.com/v1/forecast"
def build_url(
self,
latitude: float,
longitude: float,
forecast_hours: int = 48,
timezone_name: str = "UTC",
) -> str:
params = {
"latitude": latitude,
"longitude": longitude,
"hourly": ",".join(
[
"temperature_2m",
"shortwave_radiation",
"cloud_cover",
]
),
"forecast_hours": forecast_hours,
"timezone": timezone_name,
}
return f"{self.base_url}?{urlencode(params)}"
def fetch_forecast(
self,
latitude: float,
longitude: float,
forecast_hours: int = 48,
) -> WeatherForecastRun:
url = self.build_url(latitude, longitude, forecast_hours)
with urlopen(url, timeout=10) as response:
payload = json.loads(response.read().decode("utf-8"))
return OpenMeteoParser().parse_forecast(
payload=payload,
latitude=latitude,
longitude=longitude,
issued_at=datetime.now(timezone.utc),
)
class OpenMeteoArchiveClient:
"""Fetches historical weather data from Open-Meteo archive."""
base_url = "https://archive-api.open-meteo.com/v1/archive"
def build_url(
self,
latitude: float,
longitude: float,
start_date: date,
end_date: date,
timezone_name: str = "UTC",
) -> str:
params = {
"latitude": latitude,
"longitude": longitude,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"hourly": ",".join(
[
"temperature_2m",
"shortwave_radiation",
]
),
"timezone": timezone_name,
}
return f"{self.base_url}?{urlencode(params)}"
def fetch_resolved_truth(
self,
latitude: float,
longitude: float,
start_date: date,
end_date: date,
) -> list[WeatherResolvedTruth]:
url = self.build_url(latitude, longitude, start_date, end_date)
with urlopen(url, timeout=20) as response:
payload = json.loads(response.read().decode("utf-8"))
return OpenMeteoArchiveParser().parse_resolved_truth(payload)
class OpenMeteoParser:
"""Converts Open-Meteo JSON into clean external forecast records."""
def parse_forecast(
self,
payload: dict[str, Any],
latitude: float,
longitude: float,
issued_at: datetime,
) -> WeatherForecastRun:
hourly = payload.get("hourly", {})
times = hourly.get("time", [])
temperatures = hourly.get("temperature_2m", [])
radiation = hourly.get("shortwave_radiation", [])
cloud_cover = hourly.get("cloud_cover", [])
points: list[WeatherForecastPoint] = []
for index, raw_time in enumerate(times):
target_at = self._parse_time(raw_time)
horizon_hours = max(
0, round((target_at - issued_at).total_seconds() / 3600)
)
points.append(
WeatherForecastPoint(
issued_at=issued_at,
target_at=target_at,
horizon_hours=horizon_hours,
temperature_c=self._at(temperatures, index),
shortwave_radiation_w_m2=self._at(radiation, index),
cloud_cover_pct=self._at(cloud_cover, index),
)
)
return WeatherForecastRun(
issued_at=issued_at,
source="open_meteo",
latitude=latitude,
longitude=longitude,
points=points,
)
def _parse_time(self, raw_time: str) -> datetime:
parsed = datetime.fromisoformat(raw_time)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _at(self, values: list[Any], index: int) -> float | None:
if index >= len(values):
return None
value = values[index]
if value is None:
return None
return float(value)
class OpenMeteoArchiveParser:
"""Converts Open-Meteo archive JSON into resolved truth records."""
def parse_resolved_truth(self, payload: dict[str, Any]) -> list[WeatherResolvedTruth]:
hourly = payload.get("hourly", {})
times = hourly.get("time", [])
temperatures = hourly.get("temperature_2m", [])
radiation = hourly.get("shortwave_radiation", [])
truth: list[WeatherResolvedTruth] = []
for index, raw_time in enumerate(times):
truth.append(
WeatherResolvedTruth(
resolved_at=self._parse_time(raw_time),
temperature_c=self._at(temperatures, index),
shortwave_radiation_w_m2=self._at(radiation, index),
source="open_meteo_archive",
)
)
return truth
def _parse_time(self, raw_time: str) -> datetime:
parsed = datetime.fromisoformat(raw_time)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _at(self, values: list[Any], index: int) -> float | None:
if index >= len(values):
return None
value = values[index]
if value is None:
return None
return float(value)
class WeatherBuilder:
"""Builds a clean database-ready set of external weather forecast records."""
def build_forecast_run(
self,
source: str,
latitude: float,
longitude: float,
points: list[WeatherForecastPoint],
issued_at: datetime | None = None,
) -> WeatherForecastRun:
if issued_at is None:
issued_at = datetime.now(timezone.utc)
clean_points = [
WeatherForecastPoint(
issued_at=issued_at,
target_at=point.target_at,
horizon_hours=max(
0, round((point.target_at - issued_at).total_seconds() / 3600)
),
temperature_c=point.temperature_c,
shortwave_radiation_w_m2=point.shortwave_radiation_w_m2,
cloud_cover_pct=point.cloud_cover_pct,
source=source,
)
for point in sorted(points, key=lambda item: item.target_at)
]
return WeatherForecastRun(
issued_at=issued_at,
source=source,
latitude=latitude,
longitude=longitude,
points=clean_points,
)
def points_for_horizon(
self,
forecast_runs: list[WeatherForecastRun],
horizon_hours: int,
) -> list[WeatherForecastPoint]:
points: list[WeatherForecastPoint] = []
for run in forecast_runs:
points.extend(
point
for point in run.points
if point.horizon_hours == horizon_hours
)
return sorted(points, key=lambda point: point.target_at)
+310
View File
@@ -0,0 +1,310 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime
from gibil.classes.models import WeatherForecastPoint, WeatherResolvedTruth
@dataclass(frozen=True)
class WeatherDisplayDataset:
forecast_points: list[WeatherForecastPoint]
resolved_truth: list[WeatherResolvedTruth]
class WeatherDisplay:
"""Renders weather source data for the Astrape web UI."""
def render(self) -> str:
return """
<section class="panel weather-panel" data-module="weather-display">
<div class="panel-heading">
<div>
<h2>Weather</h2>
<p>External forecast history</p>
</div>
<div class="control-row">
<label>
Variable
<select id="weather-variable">
<option value="temperature_c">Temperature</option>
<option value="shortwave_radiation_w_m2">Solar radiation</option>
</select>
</label>
<div class="legend-control">
<div class="legend-title">Horizons</div>
<div id="weather-horizons" class="horizon-options"></div>
<div class="horizon-option">
<span class="legend-swatch truth-swatch"></span>
<span>Resolved truth</span>
</div>
</div>
</div>
</div>
<div class="chart-shell">
<canvas id="weather-chart" width="1100" height="420"></canvas>
</div>
</section>
<script>
window.astrapeModules = window.astrapeModules || {};
window.astrapeModules.weatherDisplay = (() => {
const palette = ["#60a5fa", "#34d399", "#fbbf24", "#a78bfa", "#fb7185", "#22d3ee"];
const defaultSlots = [
{ enabled: true, horizon: 4 },
{ enabled: true, horizon: 8 },
{ enabled: true, horizon: 12 },
{ enabled: false, horizon: 16 },
{ enabled: false, horizon: 24 },
{ enabled: false, horizon: 36 },
];
function init() {
const variable = document.getElementById("weather-variable");
variable.addEventListener("change", render);
buildHorizonControls();
refresh();
setInterval(refresh, 5000);
}
async function refresh() {
const response = await fetch("/api/weather", { cache: "no-store" });
const payload = await response.json();
window.astrapeWeatherData = payload;
render();
}
function buildHorizonControls() {
const horizons = document.getElementById("weather-horizons");
horizons.innerHTML = "";
const slots = loadSlots();
slots.forEach((slot, index) => {
const option = document.createElement("div");
option.className = "horizon-option";
option.innerHTML = `
<input class="horizon-enabled" type="checkbox" ${slot.enabled ? "checked" : ""}>
<span class="legend-swatch" style="background: ${palette[index]}"></span>
<input class="horizon-value" type="number" min="1" max="47" step="1" value="${slot.horizon}">
<span>h</span>
`;
const checkbox = option.querySelector(".horizon-enabled");
const value = option.querySelector(".horizon-value");
checkbox.addEventListener("change", render);
value.addEventListener("input", render);
horizons.appendChild(option);
});
}
function render() {
const payload = window.astrapeWeatherData || { forecast_points: [], resolved_truth: [] };
const variable = document.getElementById("weather-variable").value;
const selectedHorizons = selectedSlots();
saveSlots();
drawChart(payload, variable, selectedHorizons);
}
function drawChart(payload, variable, selectedHorizons) {
const canvas = document.getElementById("weather-chart");
const ctx = canvas.getContext("2d");
const series = buildSeries(payload, variable, selectedHorizons);
ctx.clearRect(0, 0, canvas.width, canvas.height);
const allPoints = series.flatMap((item) => item.points);
const now = Date.now();
const xs = allPoints.map((point) => new Date(point.target_at).getTime());
xs.push(now);
const ys = allPoints.map((point) => point.value).filter((value) => value !== null);
if (!xs.length || !ys.length) return;
const bounds = {
minX: Math.min(...xs),
maxX: Math.max(...xs),
minY: Math.min(...ys),
maxY: Math.max(...ys),
};
if (bounds.minY === bounds.maxY) {
bounds.minY -= 1;
bounds.maxY += 1;
}
drawAxes(ctx, canvas, bounds);
drawNowMarker(ctx, canvas, bounds);
series.forEach((item) => {
drawSeries(ctx, canvas, bounds, item.points, item.color, item.width);
});
}
function buildSeries(payload, variable, selectedHorizons) {
const series = [];
for (const slot of selectedHorizons) {
const points = (payload.forecast_points || [])
.filter((point) => point.horizon_hours === slot.horizon)
.map((point) => ({ target_at: point.target_at, value: point[variable] }))
.filter((point) => point.value !== null);
series.push({ label: `${slot.horizon}h forecast`, points, color: slot.color, width: 2 });
}
const truth = (payload.resolved_truth || [])
.map((point) => ({ target_at: point.resolved_at, value: point[variable] }))
.filter((point) => point.value !== null);
series.push({ label: "resolved truth", points: truth, color: "#f8fafc", width: 3 });
return series;
}
function drawAxes(ctx, canvas, bounds) {
const margin = chartMargin();
ctx.strokeStyle = "#94a3b8";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(margin.left, margin.top);
ctx.lineTo(margin.left, canvas.height - margin.bottom);
ctx.lineTo(canvas.width - margin.right, canvas.height - margin.bottom);
ctx.stroke();
ctx.fillStyle = "#475569";
ctx.font = "12px system-ui";
ctx.fillText(bounds.maxY.toFixed(1), 10, margin.top + 4);
ctx.fillText(bounds.minY.toFixed(1), 10, canvas.height - margin.bottom);
}
function drawSeries(ctx, canvas, bounds, points, color, width) {
if (!points.length) return;
const margin = chartMargin();
if (points.length === 1) {
const point = points[0];
const x = scale(new Date(point.target_at).getTime(), bounds.minX, bounds.maxX, margin.left, canvas.width - margin.right);
const y = scale(point.value, bounds.minY, bounds.maxY, canvas.height - margin.bottom, margin.top);
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, width + 3, 0, Math.PI * 2);
ctx.fill();
return;
}
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.beginPath();
points.forEach((point, index) => {
const x = scale(new Date(point.target_at).getTime(), bounds.minX, bounds.maxX, margin.left, canvas.width - margin.right);
const y = scale(point.value, bounds.minY, bounds.maxY, canvas.height - margin.bottom, margin.top);
if (index === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
}
function drawNowMarker(ctx, canvas, bounds) {
const now = Date.now();
if (now < bounds.minX || now > bounds.maxX) return;
const margin = chartMargin();
const x = scale(now, bounds.minX, bounds.maxX, margin.left, canvas.width - margin.right);
ctx.save();
ctx.strokeStyle = "#f8fafc";
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.moveTo(x, margin.top);
ctx.lineTo(x, canvas.height - margin.bottom);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = "#f8fafc";
ctx.font = "12px system-ui";
ctx.fillText("now", Math.min(x + 8, canvas.width - margin.right - 28), margin.top + 14);
ctx.restore();
}
function selectedSlots() {
return [...document.querySelectorAll("#weather-horizons .horizon-option")]
.map((item, index) => {
const enabled = item.querySelector(".horizon-enabled").checked;
const input = item.querySelector(".horizon-value");
const horizon = clamp(Number(input.value), 1, 47);
input.value = horizon;
return enabled ? { horizon, color: palette[index] } : null;
})
.filter(Boolean);
}
function saveSlots() {
const slots = [...document.querySelectorAll("#weather-horizons .horizon-option")]
.map((item) => ({
enabled: item.querySelector(".horizon-enabled").checked,
horizon: Number(item.querySelector(".horizon-value").value),
}));
localStorage.setItem("astrapeWeatherHorizonSlots", JSON.stringify(slots));
}
function loadSlots() {
try {
const parsed = JSON.parse(localStorage.getItem("astrapeWeatherHorizonSlots"));
if (Array.isArray(parsed) && parsed.length === 6) return parsed;
} catch (error) {
return defaultSlots;
}
return defaultSlots;
}
function clamp(value, min, max) {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, Math.round(value)));
}
function chartMargin() {
return { top: 24, right: 28, bottom: 34, left: 52 };
}
function scale(value, inMin, inMax, outMin, outMax) {
if (inMin === inMax) return (outMin + outMax) / 2;
return outMin + ((value - inMin) / (inMax - inMin)) * (outMax - outMin);
}
return { init };
})();
window.astrapeModules.weatherDisplay.init();
</script>
"""
def data_payload(self, dataset: WeatherDisplayDataset | None = None) -> str:
if dataset is None:
dataset = WeatherDisplayDataset(forecast_points=[], resolved_truth=[])
forecast_points = [self._forecast_point(point) for point in dataset.forecast_points]
resolved_truth = [self._truth_point(point) for point in dataset.resolved_truth]
horizons = sorted({point["horizon_hours"] for point in forecast_points})
return json.dumps(
{
"forecast_points": forecast_points,
"resolved_truth": resolved_truth,
"horizons": horizons,
"min_horizon": 1,
"max_horizon": 47,
}
)
def _forecast_point(self, point: WeatherForecastPoint) -> dict[str, object]:
return {
"issued_at": self._iso(point.issued_at),
"target_at": self._iso(point.target_at),
"horizon_hours": point.horizon_hours,
"source": point.source,
"temperature_c": point.temperature_c,
"shortwave_radiation_w_m2": point.shortwave_radiation_w_m2,
"cloud_cover_pct": point.cloud_cover_pct,
}
def _truth_point(self, point: WeatherResolvedTruth) -> dict[str, object]:
return {
"resolved_at": self._iso(point.resolved_at),
"source": point.source,
"temperature_c": point.temperature_c,
"shortwave_radiation_w_m2": point.shortwave_radiation_w_m2,
}
def _iso(self, value: datetime) -> str:
return value.isoformat()
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from math import pi, sin
from gibil.classes.models import WeatherForecastPoint, WeatherResolvedTruth
from gibil.classes.weather_display import WeatherDisplayDataset
class WeatherSampleData:
"""Builds temporary display-only weather data for UI tuning."""
def build(self, hours: int = 72) -> WeatherDisplayDataset:
now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
start = now - timedelta(hours=hours)
horizons = [2, 4, 8, 12, 24]
forecast_points: list[WeatherForecastPoint] = []
resolved_truth: list[WeatherResolvedTruth] = []
for offset in range(hours + 1):
target_at = start + timedelta(hours=offset)
truth_temperature = self._temperature(target_at, offset)
truth_solar = self._solar(target_at, offset)
resolved_truth.append(
WeatherResolvedTruth(
resolved_at=target_at,
source="sample",
temperature_c=truth_temperature,
shortwave_radiation_w_m2=truth_solar,
)
)
for horizon in horizons:
forecast_points.append(
WeatherForecastPoint(
issued_at=target_at - timedelta(hours=horizon),
target_at=target_at,
horizon_hours=horizon,
temperature_c=truth_temperature
+ self._temperature_error(offset, horizon),
shortwave_radiation_w_m2=max(
0,
truth_solar + self._solar_error(offset, horizon),
),
cloud_cover_pct=max(
0,
min(100, 45 + 30 * sin((offset + horizon) / 9)),
),
source="sample",
)
)
return WeatherDisplayDataset(
forecast_points=forecast_points,
resolved_truth=resolved_truth,
)
def _temperature(self, target_at: datetime, offset: int) -> float:
daily = sin(((target_at.hour - 7) / 24) * 2 * pi)
slow = sin(offset / 18)
return round(6.5 + daily * 5.5 + slow * 1.3, 1)
def _solar(self, target_at: datetime, offset: int) -> float:
daylight = max(0, sin(((target_at.hour - 5) / 15) * pi))
cloud_effect = 0.75 + 0.25 * sin(offset / 7)
return round(780 * daylight * cloud_effect, 1)
def _temperature_error(self, offset: int, horizon: int) -> float:
return round((horizon / 8) * sin((offset + horizon) / 5), 1)
def _solar_error(self, offset: int, horizon: int) -> float:
return round((horizon * 9) * sin((offset + horizon) / 4), 1)
+278
View File
@@ -0,0 +1,278 @@
from __future__ import annotations
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from os import environ
from typing import Iterator
from gibil.classes.models import WeatherForecastPoint, WeatherForecastRun, WeatherResolvedTruth
from gibil.classes.weather_display import WeatherDisplayDataset
class WeatherStoreConfigurationError(RuntimeError):
pass
@dataclass(frozen=True)
class WeatherStoreConfig:
database_url: str
@classmethod
def from_env(cls) -> "WeatherStoreConfig":
database_url = environ.get("ASTRAPE_DATABASE_URL")
if not database_url:
raise WeatherStoreConfigurationError(
"ASTRAPE_DATABASE_URL is required for weather storage"
)
return cls(database_url=database_url)
class WeatherStore:
"""Persists external weather forecasts and resolved truth in TimescaleDB."""
def __init__(self, config: WeatherStoreConfig) -> None:
self.config = config
@classmethod
def from_env(cls) -> "WeatherStore":
return cls(WeatherStoreConfig.from_env())
def initialize(self) -> None:
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute("CREATE EXTENSION IF NOT EXISTS timescaledb")
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS weather_forecast_points (
issued_at TIMESTAMPTZ NOT NULL,
target_at TIMESTAMPTZ NOT NULL,
horizon_hours INTEGER NOT NULL,
source TEXT NOT NULL,
latitude DOUBLE PRECISION NOT NULL,
longitude DOUBLE PRECISION NOT NULL,
temperature_c DOUBLE PRECISION,
shortwave_radiation_w_m2 DOUBLE PRECISION,
cloud_cover_pct DOUBLE PRECISION,
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (issued_at, target_at, source)
)
"""
)
cursor.execute(
"""
SELECT create_hypertable(
'weather_forecast_points',
'target_at',
if_not_exists => TRUE
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS weather_resolved_truth (
resolved_at TIMESTAMPTZ NOT NULL,
source TEXT NOT NULL,
temperature_c DOUBLE PRECISION,
shortwave_radiation_w_m2 DOUBLE PRECISION,
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (resolved_at, source)
)
"""
)
cursor.execute(
"""
SELECT create_hypertable(
'weather_resolved_truth',
'resolved_at',
if_not_exists => TRUE
)
"""
)
connection.commit()
def save_forecast_run(self, forecast_run: WeatherForecastRun) -> int:
rows = [
(
point.issued_at,
point.target_at,
point.horizon_hours,
forecast_run.source,
forecast_run.latitude,
forecast_run.longitude,
point.temperature_c,
point.shortwave_radiation_w_m2,
point.cloud_cover_pct,
)
for point in forecast_run.points
]
if not rows:
return 0
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.executemany(
"""
INSERT INTO weather_forecast_points (
issued_at,
target_at,
horizon_hours,
source,
latitude,
longitude,
temperature_c,
shortwave_radiation_w_m2,
cloud_cover_pct
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (issued_at, target_at, source)
DO UPDATE SET
horizon_hours = EXCLUDED.horizon_hours,
latitude = EXCLUDED.latitude,
longitude = EXCLUDED.longitude,
temperature_c = EXCLUDED.temperature_c,
shortwave_radiation_w_m2 = EXCLUDED.shortwave_radiation_w_m2,
cloud_cover_pct = EXCLUDED.cloud_cover_pct,
inserted_at = now()
""",
rows,
)
connection.commit()
return len(rows)
def save_resolved_truth(self, truth_points: list[WeatherResolvedTruth]) -> int:
rows = [
(
point.resolved_at,
point.source,
point.temperature_c,
point.shortwave_radiation_w_m2,
)
for point in truth_points
]
if not rows:
return 0
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.executemany(
"""
INSERT INTO weather_resolved_truth (
resolved_at,
source,
temperature_c,
shortwave_radiation_w_m2
)
VALUES (%s, %s, %s, %s)
ON CONFLICT (resolved_at, source)
DO UPDATE SET
temperature_c = EXCLUDED.temperature_c,
shortwave_radiation_w_m2 = EXCLUDED.shortwave_radiation_w_m2,
inserted_at = now()
""",
rows,
)
connection.commit()
return len(rows)
def save_zero_hour_forecast_as_truth(
self, forecast_run: WeatherForecastRun
) -> int:
truth_points = [
WeatherResolvedTruth(
resolved_at=point.target_at,
source="open_meteo_zero_hour",
temperature_c=point.temperature_c,
shortwave_radiation_w_m2=point.shortwave_radiation_w_m2,
)
for point in forecast_run.points
if point.horizon_hours == 0
]
return self.save_resolved_truth(truth_points)
def load_display_dataset(
self,
start_at: datetime | None = None,
end_at: datetime | None = None,
) -> WeatherDisplayDataset:
now = datetime.now(timezone.utc)
if start_at is None:
start_at = now - timedelta(hours=24)
if end_at is None:
end_at = now + timedelta(hours=48)
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT
issued_at,
target_at,
horizon_hours,
source,
temperature_c,
shortwave_radiation_w_m2,
cloud_cover_pct
FROM weather_forecast_points
WHERE target_at >= %s AND target_at <= %s
ORDER BY target_at, horizon_hours
LIMIT 5000
""",
(start_at, end_at),
)
forecast_rows = cursor.fetchall()
cursor.execute(
"""
SELECT
resolved_at,
source,
temperature_c,
shortwave_radiation_w_m2
FROM weather_resolved_truth
WHERE resolved_at >= %s AND resolved_at <= %s
ORDER BY resolved_at
LIMIT 5000
""",
(start_at, end_at),
)
truth_rows = cursor.fetchall()
return WeatherDisplayDataset(
forecast_points=[
WeatherForecastPoint(
issued_at=row[0],
target_at=row[1],
horizon_hours=row[2],
source=row[3],
temperature_c=row[4],
shortwave_radiation_w_m2=row[5],
cloud_cover_pct=row[6],
)
for row in forecast_rows
],
resolved_truth=[
WeatherResolvedTruth(
resolved_at=row[0],
source=row[1],
temperature_c=row[2],
shortwave_radiation_w_m2=row[3],
)
for row in truth_rows
],
)
@contextmanager
def _connection(self) -> Iterator[object]:
try:
import psycopg
except ImportError as error:
raise WeatherStoreConfigurationError(
"Install dependencies with `python3 -m pip install -r requirements.txt`"
) from error
with psycopg.connect(self.config.database_url) as connection:
yield connection
+254
View File
@@ -0,0 +1,254 @@
from __future__ import annotations
from os import environ
from gibil.classes.env_loader import EnvLoader
from gibil.classes.weather_sample_data import WeatherSampleData
from gibil.classes.weather_store import WeatherStore, WeatherStoreConfigurationError
from gibil.classes.weather_display import WeatherDisplay
class WebUI:
"""Composes Astrape web modules into one page."""
def __init__(self) -> None:
self.weather_display = WeatherDisplay()
def render_page(self) -> str:
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Astrape</title>
<style>
:root {{
color-scheme: light;
--bg: #11151c;
--surface: #181e27;
--panel: #1f2733;
--ink: #edf2f7;
--muted: #9aa8ba;
--line: #344052;
--field: #121821;
}}
* {{
box-sizing: border-box;
}}
body {{
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.4;
}}
header {{
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 22px 28px;
border-bottom: 1px solid var(--line);
background: var(--surface);
}}
h1, h2, p {{
margin: 0;
}}
h1 {{
font-size: 22px;
font-weight: 700;
}}
h2 {{
font-size: 18px;
font-weight: 700;
}}
p {{
color: var(--muted);
font-size: 13px;
}}
main {{
width: min(1280px, 100%);
margin: 0 auto;
padding: 24px;
}}
.panel {{
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
padding: 18px;
}}
.panel-heading {{
display: grid;
grid-template-columns: minmax(180px, auto) 1fr;
gap: 24px;
margin-bottom: 18px;
}}
.control-row {{
display: flex;
align-items: end;
justify-content: flex-end;
flex-wrap: wrap;
gap: 14px;
}}
label {{
display: grid;
gap: 6px;
color: var(--muted);
font-size: 12px;
font-weight: 600;
}}
select {{
min-width: 150px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--field);
color: var(--ink);
padding: 8px;
font: inherit;
}}
.legend-control {{
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px 12px;
color: var(--muted);
font-size: 12px;
font-weight: 600;
}}
.legend-title {{
line-height: 1;
}}
.horizon-options {{
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
}}
.horizon-option {{
display: flex;
align-items: center;
gap: 8px;
color: var(--ink);
font-size: 13px;
font-weight: 500;
line-height: 1.2;
}}
.horizon-option input {{
width: 14px;
height: 14px;
margin: 0;
}}
.horizon-option .horizon-value {{
width: 54px;
height: 28px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--field);
color: var(--ink);
padding: 4px 6px;
font: inherit;
}}
.legend-swatch {{
width: 22px;
height: 3px;
border-radius: 999px;
flex: 0 0 auto;
}}
.truth-swatch {{
background: #f8fafc;
}}
.chart-shell {{
position: relative;
min-height: 420px;
border: 1px solid var(--line);
border-radius: 6px;
overflow: hidden;
background: var(--panel);
}}
canvas {{
display: block;
width: 100%;
height: 420px;
}}
@media (max-width: 760px) {{
header, .panel-heading, .control-row {{
display: grid;
}}
.legend-control, .horizon-options {{
justify-content: flex-start;
}}
main {{
padding: 14px;
}}
select {{
width: 100%;
}}
}}
</style>
</head>
<body>
<header>
<h1>Astrape</h1>
<p>Gibil web UI</p>
</header>
<main>
{self.weather_display.render()}
</main>
<script>
let astrapeUiVersion = null;
async function watchUiVersion() {{
const response = await fetch("/api/ui-version", {{ cache: "no-store" }});
const payload = await response.json();
if (astrapeUiVersion === null) {{
astrapeUiVersion = payload.version;
return;
}}
if (astrapeUiVersion !== payload.version) {{
window.location.reload();
}}
}}
setInterval(watchUiVersion, 1000);
watchUiVersion();
</script>
</body>
</html>"""
def weather_payload(self) -> str:
EnvLoader().load()
if environ.get("ASTRAPE_WEB_SAMPLE_DATA") == "1":
return self.weather_display.data_payload(WeatherSampleData().build())
try:
dataset = WeatherStore.from_env().load_display_dataset()
except WeatherStoreConfigurationError:
dataset = None
return self.weather_display.data_payload(dataset)
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from os import environ
from sys import stderr
from time import sleep
from gibil.classes.env_loader import EnvLoader
from gibil.classes.weather_builder import (
OpenMeteoArchiveClient,
OpenMeteoClient,
WeatherBuilder,
)
from gibil.classes.weather_store import WeatherStore
@dataclass(frozen=True)
class DbDaemonConfig:
latitude: float
longitude: float
forecast_hours: int
truth_lookback_days: int
truth_end_delay_days: int
poll_seconds: int
@classmethod
def from_env(cls) -> "DbDaemonConfig":
return cls(
latitude=float(_required_env("ASTRAPE_LATITUDE")),
longitude=float(_required_env("ASTRAPE_LONGITUDE")),
forecast_hours=int(environ.get("ASTRAPE_WEATHER_FORECAST_HOURS", "48")),
truth_lookback_days=int(
environ.get("ASTRAPE_WEATHER_TRUTH_LOOKBACK_DAYS", "14")
),
truth_end_delay_days=int(
environ.get("ASTRAPE_WEATHER_TRUTH_END_DELAY_DAYS", "5")
),
poll_seconds=int(environ.get("ASTRAPE_WEATHER_POLL_SECONDS", "3600")),
)
class DbDaemon:
"""Runs builder components that populate Astrape's database."""
def __init__(
self,
config: DbDaemonConfig,
weather_client: OpenMeteoClient,
archive_client: OpenMeteoArchiveClient,
weather_builder: WeatherBuilder,
weather_store: WeatherStore,
) -> None:
self.config = config
self.weather_client = weather_client
self.archive_client = archive_client
self.weather_builder = weather_builder
self.weather_store = weather_store
@classmethod
def from_env(cls) -> "DbDaemon":
return cls(
config=DbDaemonConfig.from_env(),
weather_client=OpenMeteoClient(),
archive_client=OpenMeteoArchiveClient(),
weather_builder=WeatherBuilder(),
weather_store=WeatherStore.from_env(),
)
def initialize(self) -> None:
self.weather_store.initialize()
def run_once(self) -> tuple[int, int]:
raw_run = self.weather_client.fetch_forecast(
latitude=self.config.latitude,
longitude=self.config.longitude,
forecast_hours=self.config.forecast_hours,
)
forecast_run = self.weather_builder.build_forecast_run(
source=raw_run.source,
latitude=raw_run.latitude,
longitude=raw_run.longitude,
points=raw_run.points,
issued_at=raw_run.issued_at,
)
forecast_count = self.weather_store.save_forecast_run(forecast_run)
zero_hour_truth_count = self.weather_store.save_zero_hour_forecast_as_truth(
forecast_run
)
today = datetime.now(timezone.utc).date()
truth_end = today - timedelta(days=self.config.truth_end_delay_days)
truth_start = truth_end - timedelta(days=self.config.truth_lookback_days)
truth_points = self.archive_client.fetch_resolved_truth(
latitude=self.config.latitude,
longitude=self.config.longitude,
start_date=truth_start,
end_date=truth_end,
)
archive_truth_count = self.weather_store.save_resolved_truth(truth_points)
return forecast_count, zero_hour_truth_count + archive_truth_count
def run_forever(self) -> None:
self.initialize()
while True:
forecast_count, truth_count = self.run_once()
print(
f"stored_weather_forecast_points={forecast_count} "
f"stored_weather_resolved_truth={truth_count}",
flush=True,
)
sleep(self.config.poll_seconds)
def main() -> None:
try:
EnvLoader().load()
daemon = DbDaemon.from_env()
daemon.run_forever()
except Exception as error:
print(f"db_daemon_startup_error={error}", file=stderr)
raise SystemExit(1) from error
def _required_env(name: str) -> str:
value = environ.get(name)
if not value:
raise RuntimeError(
f"{name} is required. Set ASTRAPE_DATABASE_URL, "
"ASTRAPE_LATITUDE, and ASTRAPE_LONGITUDE before starting db_daemon."
)
return value
if __name__ == "__main__":
main()
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from importlib import import_module, reload
from os import environ
from pathlib import Path
import json
from gibil.classes.env_loader import EnvLoader
EnvLoader().load()
HOST = environ.get("ASTRAPE_WEB_HOST", "0.0.0.0")
PORT = int(environ.get("ASTRAPE_WEB_PORT", "8080"))
PROJECT_ROOT = Path(__file__).resolve().parents[2]
WATCHED_PATHS = [
PROJECT_ROOT / "gibil" / "classes" / "webui.py",
PROJECT_ROOT / "gibil" / "classes" / "weather_display.py",
PROJECT_ROOT / "gibil" / "classes" / "weather_store.py",
]
class AstrapeWebHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
if self.path == "/":
self._send_html(self._webui().render_page())
return
if self.path == "/api/weather":
self._send_json_text(self._webui().weather_payload())
return
if self.path == "/api/ui-version":
self._send_json_text(json.dumps({"version": self._ui_version()}))
return
self.send_error(404)
def log_message(self, format: str, *args: object) -> None:
print(f"{self.address_string()} - {format % args}")
def _webui(self):
weather_store_module = import_module("gibil.classes.weather_store")
weather_display_module = import_module("gibil.classes.weather_display")
webui_module = import_module("gibil.classes.webui")
reload(weather_store_module)
reload(weather_display_module)
reload(webui_module)
return webui_module.WebUI()
def _ui_version(self) -> str:
mtimes = [
str(path.stat().st_mtime_ns)
for path in WATCHED_PATHS
if path.exists()
]
return ".".join(mtimes)
def _send_html(self, body: str) -> None:
encoded = body.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def _send_json_text(self, body: str) -> None:
encoded = body.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def main() -> None:
server = ThreadingHTTPServer((HOST, PORT), AstrapeWebHandler)
print(f"Astrape web UI listening on http://{HOST}:{PORT}")
server.serve_forever()
if __name__ == "__main__":
main()