5b562338ab
Everything that talks to the energy backend: the Sigenergy Modbus client, its register map, the plant store, and the polling daemon. Sits at the base of the Astrape dependency chain, because the plant store is what satisfies gibil's PlantHistoryPort -- the oracle cannot forecast without observed plant history. EnvLoader lives here for the same reason: it is the only place all three repos can see it without a fourth repo for 37 lines. EnvLoader no longer resolves env/ from __file__. Where a package is installed says nothing about where the deployment keeps its secrets; it now uses the working directory, or ASTRAPE_ENV_DIR. Extracted from a single repository where the forecasting engine, the database plumbing, the hardware client, the ingest daemons and the web UI all imported each other freely. Verified that every module imports with gibil resolved from its own installed checkout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
199 lines
5.7 KiB
Python
199 lines
5.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Debug script to inspect stored Sigenergy plant snapshots."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from datetime import timezone
|
|
import json
|
|
|
|
from astrape_ems.classes.env import EnvLoader
|
|
from astrape_ems.classes.store import SigenStore
|
|
|
|
|
|
def main() -> None:
|
|
EnvLoader().load()
|
|
args = parse_args()
|
|
store = SigenStore.from_env()
|
|
|
|
if args.view == "raw":
|
|
rows = load_raw_snapshots(store, args.limit)
|
|
print_raw_snapshots(rows)
|
|
return
|
|
|
|
rows = load_rollup(store, args.view, args.limit)
|
|
print_rollup(rows, args.view)
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Inspect stored Sigenergy plant snapshots."
|
|
)
|
|
parser.add_argument(
|
|
"--view",
|
|
choices=("raw", "1m", "15m", "1h"),
|
|
default="raw",
|
|
help="View to inspect. Defaults to raw.",
|
|
)
|
|
parser.add_argument(
|
|
"--limit",
|
|
type=int,
|
|
default=10,
|
|
help="Number of most recent rows/buckets to show. Defaults to 10.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def load_raw_snapshots(store: SigenStore, limit: int) -> list[tuple]:
|
|
with store._connection() as connection:
|
|
with connection.cursor() as cursor:
|
|
cursor.execute(
|
|
"""
|
|
SELECT
|
|
observed_at,
|
|
received_at,
|
|
solar_power_w,
|
|
load_power_w,
|
|
battery_soc_pct,
|
|
battery_power_w,
|
|
grid_import_w,
|
|
grid_export_w,
|
|
plant_active_power_w,
|
|
accumulated_pv_energy_kwh,
|
|
daily_consumed_energy_kwh,
|
|
raw_values
|
|
FROM sigen_plant_snapshots
|
|
ORDER BY observed_at DESC
|
|
LIMIT %s
|
|
""",
|
|
(limit,),
|
|
)
|
|
return cursor.fetchall()
|
|
|
|
|
|
def load_rollup(store: SigenStore, view: str, limit: int) -> list[tuple]:
|
|
view_name = {
|
|
"1m": "sigen_plant_snapshots_1m",
|
|
"15m": "sigen_plant_snapshots_15m",
|
|
"1h": "sigen_plant_snapshots_1h",
|
|
}[view]
|
|
|
|
with store._connection() as connection:
|
|
with connection.cursor() as cursor:
|
|
cursor.execute(
|
|
f"""
|
|
SELECT
|
|
bucket,
|
|
sample_count,
|
|
avg_solar_power_w,
|
|
max_solar_power_w,
|
|
avg_load_power_w,
|
|
max_load_power_w,
|
|
avg_grid_import_w,
|
|
max_grid_import_w,
|
|
avg_grid_export_w,
|
|
max_grid_export_w,
|
|
avg_battery_soc_pct
|
|
FROM {view_name}
|
|
ORDER BY bucket DESC
|
|
LIMIT %s
|
|
""",
|
|
(limit,),
|
|
)
|
|
return cursor.fetchall()
|
|
|
|
|
|
def print_raw_snapshots(rows: list[tuple]) -> None:
|
|
print(f"raw_snapshots={len(rows)}")
|
|
for row in rows:
|
|
(
|
|
observed_at,
|
|
received_at,
|
|
solar_power_w,
|
|
load_power_w,
|
|
battery_soc_pct,
|
|
battery_power_w,
|
|
grid_import_w,
|
|
grid_export_w,
|
|
plant_active_power_w,
|
|
accumulated_pv_energy_kwh,
|
|
daily_consumed_energy_kwh,
|
|
raw_values,
|
|
) = row
|
|
print(
|
|
f"{_fmt_time(observed_at)} "
|
|
f"solar={_fmt_w(solar_power_w)} "
|
|
f"load={_fmt_w(load_power_w)} "
|
|
f"soc={_fmt_pct(battery_soc_pct)} "
|
|
f"battery={_fmt_w(battery_power_w)} "
|
|
f"import={_fmt_w(grid_import_w)} "
|
|
f"export={_fmt_w(grid_export_w)} "
|
|
f"plant={_fmt_w(plant_active_power_w)} "
|
|
f"pv_total={_fmt_kwh(accumulated_pv_energy_kwh)} "
|
|
f"load_today={_fmt_kwh(daily_consumed_energy_kwh)} "
|
|
f"lag_s={(received_at - observed_at).total_seconds():.1f}"
|
|
)
|
|
if raw_values and any(key.endswith("_error") for key in raw_values):
|
|
errors = {
|
|
key: value
|
|
for key, value in raw_values.items()
|
|
if key.endswith("_error")
|
|
}
|
|
print(f" errors={json.dumps(errors, default=str)}")
|
|
|
|
|
|
def print_rollup(rows: list[tuple], view: str) -> None:
|
|
print(f"{view}_buckets={len(rows)}")
|
|
for row in rows:
|
|
(
|
|
bucket,
|
|
sample_count,
|
|
avg_solar_power_w,
|
|
max_solar_power_w,
|
|
avg_load_power_w,
|
|
max_load_power_w,
|
|
avg_grid_import_w,
|
|
max_grid_import_w,
|
|
avg_grid_export_w,
|
|
max_grid_export_w,
|
|
avg_battery_soc_pct,
|
|
) = row
|
|
print(
|
|
f"{_fmt_time(bucket)} samples={sample_count:4} "
|
|
f"solar_avg={_fmt_w(avg_solar_power_w)} "
|
|
f"solar_max={_fmt_w(max_solar_power_w)} "
|
|
f"load_avg={_fmt_w(avg_load_power_w)} "
|
|
f"load_max={_fmt_w(max_load_power_w)} "
|
|
f"import_avg={_fmt_w(avg_grid_import_w)} "
|
|
f"import_max={_fmt_w(max_grid_import_w)} "
|
|
f"export_avg={_fmt_w(avg_grid_export_w)} "
|
|
f"export_max={_fmt_w(max_grid_export_w)} "
|
|
f"soc_avg={_fmt_pct(avg_battery_soc_pct)}"
|
|
)
|
|
|
|
|
|
def _fmt_time(value) -> str:
|
|
return value.astimezone(timezone.utc).isoformat(timespec="seconds")
|
|
|
|
|
|
def _fmt_w(value) -> str:
|
|
if value is None:
|
|
return "None"
|
|
return f"{value:.0f}W"
|
|
|
|
|
|
def _fmt_pct(value) -> str:
|
|
if value is None:
|
|
return "None"
|
|
return f"{value:.1f}%"
|
|
|
|
|
|
def _fmt_kwh(value) -> str:
|
|
if value is None:
|
|
return "None"
|
|
return f"{value:.2f}kWh"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|