From 029bec240d77cf5d7ea03b14b9f2de71c51d6874 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 22:02:05 +0000 Subject: [PATCH 1/4] Add history subcommand to explore recent SolMate logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes solmate_sdk's get_recent_logs via a new read-only command that fetches PV / injection / battery time-series data from the cloud and prints a structure summary. --raw dumps JSON to stdout, --dump writes it to a file for offline analysis. Intended as a first exploration step toward computing daily averages and trend lines over the last N days — the response schema is not documented, so the summary keeps us honest before building aggregation logic. https://claude.ai/code/session_01XkPiC4MQxgGaybR8ivBXd3 --- CHANGELOG.md | 3 ++ README.md | 5 ++ pyproject.toml | 1 + src/solmate_optimizer/__main__.py | 2 + src/solmate_optimizer/history.py | 82 +++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+) create mode 100644 src/solmate_optimizer/history.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b173f1..67c1b55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- New `history` subcommand (also standalone entry point) that fetches recent logs from the SolMate cloud via `get_recent_logs` and summarizes the response structure. Supports `--days N`, `--raw` (dump JSON to stdout), and `--dump FILE` (write JSON to disk). Intended as a first step toward daily averages and trend analysis over historical PV, injection, and battery data. + ## [0.5.1] - 2026-04-16 ### Added diff --git a/README.md b/README.md index f9889ea..78c5513 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ solmate optimize --dry-run # compute profile, don't write solmate optimize --no-activate # write but don't activate status # read-only status view status --graph # status with plotext profile graphs +history --days 7 # fetch recent PV/injection/battery logs ``` When using `uvx`, prefix every command with `uvx --from solmate-optimizer@latest` (e.g. `uvx --from solmate-optimizer@latest solmate optimize --dry-run`). When working from a checkout, prefix with `uv run`. @@ -168,6 +169,10 @@ When using `uvx`, prefix every command with `uvx --from solmate-optimizer@latest | `status` | Show live values and injection profiles (read-only, no OWM/aWATTar needed) | | `status --graph` | Same, with ASCII art visualization of each profile | | `status --max-watts 600` | Override max watts for display (also via `MAX_WATTS` env) | +| `history` | Fetch recent logs (PV, injection, battery) from the cloud and summarize structure | +| `history --days 7` | Fetch the last 7 days of logs | +| `history --raw` | Dump the full JSON response to stdout | +| `history --dump logs.json` | Write the full JSON response to a file for offline analysis | ### Example output diff --git a/pyproject.toml b/pyproject.toml index 9af0620..3f45fcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ [project.scripts] solmate = "solmate_optimizer.__main__:cli" status = "solmate_optimizer.status:status" +history = "solmate_optimizer.history:history" [dependency-groups] dev = ["pytest", "pytest-cov"] diff --git a/src/solmate_optimizer/__main__.py b/src/solmate_optimizer/__main__.py index 6ff046c..916e5c7 100644 --- a/src/solmate_optimizer/__main__.py +++ b/src/solmate_optimizer/__main__.py @@ -1,5 +1,6 @@ import click +from solmate_optimizer.history import history from solmate_optimizer.main import optimize from solmate_optimizer.status import status @@ -13,5 +14,6 @@ def cli(ctx: click.Context): cli.add_command(optimize) cli.add_command(status) +cli.add_command(history) cli() diff --git a/src/solmate_optimizer/history.py b/src/solmate_optimizer/history.py new file mode 100644 index 0000000..425b6f7 --- /dev/null +++ b/src/solmate_optimizer/history.py @@ -0,0 +1,82 @@ +"""Read-only SolMate history: explore recent logs (PV, injection, battery over time). + +The SolMate cloud API exposes a `logs` route (via `solmate_sdk.SolMateAPIClient.get_recent_logs`) +that returns time-series data for the last N days. The exact response shape is not documented; +this command fetches the data and prints a structure summary so callers can decide how to +aggregate it (daily averages, trend lines, etc.). +""" + +import datetime +import json +import os +import sys +from typing import Any + +import click + +from solmate_optimizer.main import connect_solmate + + +def _summarize(value: Any, depth: int = 0, max_depth: int = 3) -> str: + """Return a short human-readable description of a JSON-ish value.""" + indent = " " * depth + if isinstance(value, dict): + if depth >= max_depth: + return f"dict({len(value)} keys)" + lines = [f"dict with {len(value)} keys:"] + for k, v in value.items(): + lines.append(f"{indent} {k}: {_summarize(v, depth + 1, max_depth)}") + return "\n".join(lines) + if isinstance(value, list): + if not value: + return "list(empty)" + sample = value[0] + if isinstance(sample, (dict, list)): + return f"list(len={len(value)}, item[0]={_summarize(sample, depth + 1, max_depth)})" + types = {type(v).__name__ for v in value[:50]} + return f"list(len={len(value)}, item types={sorted(types)})" + if isinstance(value, str): + preview = value if len(value) <= 40 else value[:37] + "..." + return f"str({preview!r})" + return f"{type(value).__name__}({value!r})" + + +@click.command() +@click.option("--days", type=int, default=1, help="Number of days of history to fetch (default: 1)") +@click.option("--raw", is_flag=True, help="Dump full JSON response to stdout") +@click.option("--dump", "dump_path", type=click.Path(dir_okay=False, writable=True), + default=None, help="Write full JSON response to this file") +def history(days: int, raw: bool, dump_path: str | None): + """Fetch recent logs (PV, injection, battery) from the SolMate cloud and summarize them.""" + serial = os.environ.get("SOLMATE_SERIAL") + password = os.environ.get("SOLMATE_PASSWORD") + if not serial or not password: + click.echo("Error: SOLMATE_SERIAL and SOLMATE_PASSWORD must be set", err=True) + sys.exit(1) + + try: + client = connect_solmate(serial, password) + except Exception as e: + click.echo(f"SolMate connection failed: {e}", err=True) + sys.exit(1) + + start = datetime.datetime.now() - datetime.timedelta(days=days) + click.echo(f"Fetching {days} day(s) of logs since {start.isoformat(timespec='seconds')} ...") + + try: + data = client.get_recent_logs(days=days) + except Exception as e: + click.echo(f"Failed to fetch logs: {e}", err=True) + sys.exit(1) + + if dump_path: + with open(dump_path, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2, default=str) + click.echo(f"Wrote raw response to {dump_path}") + + if raw: + click.echo(json.dumps(data, indent=2, default=str)) + return + + click.echo("\nResponse structure:") + click.echo(_summarize(data)) From f18ddd882e95654991f2476bdbb65adaca19f6e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 22:06:16 +0000 Subject: [PATCH 2/4] Plot history: 3 curves overlaid on a shared time axis Adds --plot to the history subcommand: renders PV (orange), injection (blue) and battery state (green, scaled to % on the same watts axis) as a single plotext chart. A flexible extractor walks the undocumented response, matches timestamp / pv / inject / battery keys against a candidate list, and falls back to a clear error that points at --raw for inspection. Also adds --from-file so a previously dumped JSON can be re-plotted offline without hitting the cloud. https://claude.ai/code/session_01XkPiC4MQxgGaybR8ivBXd3 --- CHANGELOG.md | 1 + README.md | 2 + src/solmate_optimizer/history.py | 211 +++++++++++++++++++++++++++---- 3 files changed, 191 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67c1b55..a73dec0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - New `history` subcommand (also standalone entry point) that fetches recent logs from the SolMate cloud via `get_recent_logs` and summarizes the response structure. Supports `--days N`, `--raw` (dump JSON to stdout), and `--dump FILE` (write JSON to disk). Intended as a first step toward daily averages and trend analysis over historical PV, injection, and battery data. +- `history --plot` renders an ASCII chart via `plotext` with PV (orange), injection (blue) and battery state (green, scaled to %) overlaid on a shared time axis. A flexible extractor handles several likely response shapes; if it fails, it prints the mapped key names and suggests running `--raw` to inspect. `--from-file FILE` re-plots a previously dumped response offline. ## [0.5.1] - 2026-04-16 diff --git a/README.md b/README.md index 78c5513..91bb5f3 100644 --- a/README.md +++ b/README.md @@ -171,8 +171,10 @@ When using `uvx`, prefix every command with `uvx --from solmate-optimizer@latest | `status --max-watts 600` | Override max watts for display (also via `MAX_WATTS` env) | | `history` | Fetch recent logs (PV, injection, battery) from the cloud and summarize structure | | `history --days 7` | Fetch the last 7 days of logs | +| `history --plot` | ASCII chart with PV / injection / battery overlaid on a shared time axis | | `history --raw` | Dump the full JSON response to stdout | | `history --dump logs.json` | Write the full JSON response to a file for offline analysis | +| `history --from-file logs.json --plot` | Re-plot a previously dumped response without hitting the cloud | ### Example output diff --git a/src/solmate_optimizer/history.py b/src/solmate_optimizer/history.py index 425b6f7..98b81ab 100644 --- a/src/solmate_optimizer/history.py +++ b/src/solmate_optimizer/history.py @@ -2,8 +2,8 @@ The SolMate cloud API exposes a `logs` route (via `solmate_sdk.SolMateAPIClient.get_recent_logs`) that returns time-series data for the last N days. The exact response shape is not documented; -this command fetches the data and prints a structure summary so callers can decide how to -aggregate it (daily averages, trend lines, etc.). +this command fetches the data, optionally prints a structure summary, and can plot the three +time series (PV power, injection, battery state) in a single ASCII chart with a shared time axis. """ import datetime @@ -13,9 +13,17 @@ from typing import Any import click +import plotext as plt from solmate_optimizer.main import connect_solmate +# Field-name candidates for each metric. The SolMate cloud schema is undocumented, +# so the extractor tries these in order. First match wins. +PV_KEYS = ("pv_power", "pv", "production", "solar", "pv_w") +INJECT_KEYS = ("inject_power", "injection", "inject", "grid_injection", "inject_w") +BATTERY_KEYS = ("battery_state", "battery", "soc", "battery_percentage") +TIMESTAMP_KEYS = ("timestamp", "time", "ts", "date", "datetime") + def _summarize(value: Any, depth: int = 0, max_depth: int = 3) -> str: """Return a short human-readable description of a JSON-ish value.""" @@ -41,33 +49,175 @@ def _summarize(value: Any, depth: int = 0, max_depth: int = 3) -> str: return f"{type(value).__name__}({value!r})" +def _find_sample_list(data: Any) -> list[dict] | None: + """Walk a nested response and return the first list of dicts that looks like samples. + + Heuristic: a sample dict contains at least one timestamp-like key and at least one + of the PV/injection/battery keys. + """ + if isinstance(data, list) and data and isinstance(data[0], dict): + sample = data[0] + keys = set(sample.keys()) + has_ts = any(k in keys for k in TIMESTAMP_KEYS) + has_metric = any(k in keys for k in PV_KEYS + INJECT_KEYS + BATTERY_KEYS) + if has_ts or has_metric: + return data + if isinstance(data, dict): + for value in data.values(): + found = _find_sample_list(value) + if found: + return found + return None + + +def _pick(sample: dict, candidates: tuple[str, ...]) -> str | None: + for key in candidates: + if key in sample: + return key + return None + + +def _parse_timestamp(value: Any) -> datetime.datetime | None: + if isinstance(value, (int, float)): + # Heuristic: > 1e12 → milliseconds, otherwise seconds + ts = value / 1000 if value > 1e12 else value + return datetime.datetime.fromtimestamp(ts) + if isinstance(value, str): + try: + return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + return None + + +def extract_series(data: Any) -> dict[str, list] | None: + """Extract (timestamps, pv, injection, battery) from a recent-logs response. + + Returns a dict with keys "t", "pv", "inject", "battery" — each a list of the same length. + Missing metrics are filled with None; the caller can decide whether to plot them. + Returns None if no sample list could be found. + """ + samples = _find_sample_list(data) + if not samples: + return None + + ts_key = _pick(samples[0], TIMESTAMP_KEYS) + pv_key = _pick(samples[0], PV_KEYS) + inj_key = _pick(samples[0], INJECT_KEYS) + bat_key = _pick(samples[0], BATTERY_KEYS) + + t: list[datetime.datetime] = [] + pv: list[float | None] = [] + inject: list[float | None] = [] + battery: list[float | None] = [] + + for s in samples: + parsed_ts = _parse_timestamp(s.get(ts_key)) if ts_key else None + if parsed_ts is None: + continue + t.append(parsed_ts) + pv.append(s.get(pv_key) if pv_key else None) + inject.append(s.get(inj_key) if inj_key else None) + battery.append(s.get(bat_key) if bat_key else None) + + return { + "t": t, "pv": pv, "inject": inject, "battery": battery, + "keys": {"timestamp": ts_key, "pv": pv_key, "inject": inj_key, "battery": bat_key}, + } + + +def _drop_nones(xs: list, ys: list) -> tuple[list, list]: + pairs = [(x, y) for x, y in zip(xs, ys) if y is not None] + if not pairs: + return [], [] + return [p[0] for p in pairs], [p[1] for p in pairs] + + +def plot_history(series: dict[str, list], max_watts: float = 800.0) -> None: + """Plot PV, injection, and battery on a single time-axis chart. + + PV and injection are plotted in watts (left axis range 0..max_watts). Battery is + normalized to the same scale as 0..100 % * max_watts / 100, so the battery curve + sweeps the same vertical space. The y-axis ticks label both views. + """ + t = series["t"] + if not t: + click.echo("No samples to plot.", err=True) + return + + plt.clf() + plt.date_form("Y-m-d H:M") + date_strings = plt.datetimes_to_strings(t, output_form="Y-m-d H:M") + plt.plotsize(100, 20) + plt.title("SolMate history: PV (orange), injection (blue), battery (green, %)") + + pv_t, pv_y = _drop_nones(date_strings, series["pv"]) + inj_t, inj_y = _drop_nones(date_strings, series["inject"]) + bat_t, bat_y_raw = _drop_nones(date_strings, series["battery"]) + + # Battery may be fraction 0–1 or percent 0–100. Detect and normalize to percent. + if bat_y_raw and max(bat_y_raw) <= 1.5: + bat_pct = [v * 100 for v in bat_y_raw] + else: + bat_pct = list(bat_y_raw) + # Scale percent to watts-axis so all three curves share the same vertical range. + bat_y = [v * max_watts / 100 for v in bat_pct] + + if pv_y: + plt.plot(pv_t, pv_y, color="orange", label="PV (W)") + if inj_y: + plt.plot(inj_t, inj_y, color="blue+", label="injection (W)") + if bat_y: + plt.plot(bat_t, bat_y, color="green", label="battery (% scaled)") + + plt.ylim(0, max_watts) + # Y ticks show watts and equivalent battery percentage side by side. + watt_ticks = [0, max_watts * 0.25, max_watts * 0.5, max_watts * 0.75, max_watts] + tick_labels = [f"{int(w)}W / {int(w * 100 / max_watts)}%" for w in watt_ticks] + plt.yticks(watt_ticks, tick_labels) + plt.xlabel("time") + plt.show() + + @click.command() @click.option("--days", type=int, default=1, help="Number of days of history to fetch (default: 1)") @click.option("--raw", is_flag=True, help="Dump full JSON response to stdout") @click.option("--dump", "dump_path", type=click.Path(dir_okay=False, writable=True), default=None, help="Write full JSON response to this file") -def history(days: int, raw: bool, dump_path: str | None): +@click.option("--plot", "show_plot", is_flag=True, + help="Render an ASCII plot with PV, injection and battery over time") +@click.option("--from-file", "from_file", type=click.Path(exists=True, dir_okay=False, readable=True), + default=None, help="Load response from a JSON file instead of the cloud (for offline analysis)") +@click.option("--max-watts", type=float, default=800.0, envvar="MAX_WATTS", + help="SolMate max injection capacity in watts (for plot scaling)") +def history(days: int, raw: bool, dump_path: str | None, show_plot: bool, + from_file: str | None, max_watts: float): """Fetch recent logs (PV, injection, battery) from the SolMate cloud and summarize them.""" - serial = os.environ.get("SOLMATE_SERIAL") - password = os.environ.get("SOLMATE_PASSWORD") - if not serial or not password: - click.echo("Error: SOLMATE_SERIAL and SOLMATE_PASSWORD must be set", err=True) - sys.exit(1) - - try: - client = connect_solmate(serial, password) - except Exception as e: - click.echo(f"SolMate connection failed: {e}", err=True) - sys.exit(1) - - start = datetime.datetime.now() - datetime.timedelta(days=days) - click.echo(f"Fetching {days} day(s) of logs since {start.isoformat(timespec='seconds')} ...") - - try: - data = client.get_recent_logs(days=days) - except Exception as e: - click.echo(f"Failed to fetch logs: {e}", err=True) - sys.exit(1) + if from_file: + with open(from_file, "r", encoding="utf-8") as fh: + data = json.load(fh) + click.echo(f"Loaded response from {from_file}") + else: + serial = os.environ.get("SOLMATE_SERIAL") + password = os.environ.get("SOLMATE_PASSWORD") + if not serial or not password: + click.echo("Error: SOLMATE_SERIAL and SOLMATE_PASSWORD must be set", err=True) + sys.exit(1) + + try: + client = connect_solmate(serial, password) + except Exception as e: + click.echo(f"SolMate connection failed: {e}", err=True) + sys.exit(1) + + start = datetime.datetime.now() - datetime.timedelta(days=days) + click.echo(f"Fetching {days} day(s) of logs since {start.isoformat(timespec='seconds')} ...") + + try: + data = client.get_recent_logs(days=days) + except Exception as e: + click.echo(f"Failed to fetch logs: {e}", err=True) + sys.exit(1) if dump_path: with open(dump_path, "w", encoding="utf-8") as fh: @@ -80,3 +230,18 @@ def history(days: int, raw: bool, dump_path: str | None): click.echo("\nResponse structure:") click.echo(_summarize(data)) + + if show_plot: + series = extract_series(data) + if series is None: + click.echo( + "\nCould not locate a sample list in the response. " + "Run with --raw or --dump FILE and inspect the shape, " + "then extend PV_KEYS / INJECT_KEYS / BATTERY_KEYS in history.py.", + err=True, + ) + sys.exit(2) + mapped = series["keys"] + click.echo(f"\nMapped keys: {mapped}") + click.echo(f"Samples: {len(series['t'])}") + plot_history(series, max_watts=max_watts) From 54c64872b478f3a23957e60244ca3b2e15cdaf3a Mon Sep 17 00:00:00 2001 From: Harald Schilly Date: Tue, 21 Apr 2026 14:33:45 +0200 Subject: [PATCH 3/4] Release v0.6.0: history plot defaults and schema-aware parser - history: plot by default (no --plot flag), default window 7 days, fills terminal width and 2/3 of height (min 30 lines). - Dual y-axis: watts (0/200/400/600/800) left, battery % (0..100) right. - Parser rewritten for the real columnar response schema (logs[*].{timestamp, pv_power, inject_power, battery_state, ...}). - --raw dumps numeric arrays, --no-plot prints structure, --from-file replays a previously dumped JSON. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 8 +- README.md | 14 +-- pyproject.toml | 2 +- src/solmate_optimizer/history.py | 184 ++++++++++++++----------------- uv.lock | 2 +- 5 files changed, 96 insertions(+), 114 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a73dec0..397b3fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] - 2026-04-21 + ### Added -- New `history` subcommand (also standalone entry point) that fetches recent logs from the SolMate cloud via `get_recent_logs` and summarizes the response structure. Supports `--days N`, `--raw` (dump JSON to stdout), and `--dump FILE` (write JSON to disk). Intended as a first step toward daily averages and trend analysis over historical PV, injection, and battery data. -- `history --plot` renders an ASCII chart via `plotext` with PV (orange), injection (blue) and battery state (green, scaled to %) overlaid on a shared time axis. A flexible extractor handles several likely response shapes; if it fails, it prints the mapped key names and suggests running `--raw` to inspect. `--from-file FILE` re-plots a previously dumped response offline. +- New `history` subcommand (also standalone entry point): plots the last N days of PV, injection and battery from the SolMate cloud on a dual-axis ASCII chart (watts on the left axis, battery % on the right) via `plotext`. The plot fills the terminal width and ~2/3 of its height (min 30 lines). Default window is 7 days. Y-axis uses round ticks (0/200/400/600/800 W, 0/20/40/60/80/100 %). `--raw` dumps the full JSON response (with numeric arrays) to stdout, `--dump FILE` writes it to disk, `--no-plot` prints a structure summary instead of plotting, and `--from-file FILE` re-plots a previously dumped response offline. ## [0.5.1] - 2026-04-16 @@ -76,7 +77,8 @@ Initial public release. - GitHub Actions release workflow using PyPI trusted publishing (OIDC). - GCP Cloud Run deployment instructions (`DEPLOYMENT.md`). -[Unreleased]: https://github.com/haraldschilly/solmate-optimizer/compare/v0.5.1...HEAD +[Unreleased]: https://github.com/haraldschilly/solmate-optimizer/compare/v0.6.0...HEAD +[0.6.0]: https://github.com/haraldschilly/solmate-optimizer/compare/v0.5.1...v0.6.0 [0.5.1]: https://github.com/haraldschilly/solmate-optimizer/compare/v0.4.0...v0.5.1 [0.4.0]: https://github.com/haraldschilly/solmate-optimizer/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/haraldschilly/solmate-optimizer/compare/v0.2.0...v0.3.0 diff --git a/README.md b/README.md index 91bb5f3..f087363 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ solmate optimize --dry-run # compute profile, don't write solmate optimize --no-activate # write but don't activate status # read-only status view status --graph # status with plotext profile graphs -history --days 7 # fetch recent PV/injection/battery logs +history # plot last 7 days of PV/injection/battery ``` When using `uvx`, prefix every command with `uvx --from solmate-optimizer@latest` (e.g. `uvx --from solmate-optimizer@latest solmate optimize --dry-run`). When working from a checkout, prefix with `uv run`. @@ -169,12 +169,12 @@ When using `uvx`, prefix every command with `uvx --from solmate-optimizer@latest | `status` | Show live values and injection profiles (read-only, no OWM/aWATTar needed) | | `status --graph` | Same, with ASCII art visualization of each profile | | `status --max-watts 600` | Override max watts for display (also via `MAX_WATTS` env) | -| `history` | Fetch recent logs (PV, injection, battery) from the cloud and summarize structure | -| `history --days 7` | Fetch the last 7 days of logs | -| `history --plot` | ASCII chart with PV / injection / battery overlaid on a shared time axis | -| `history --raw` | Dump the full JSON response to stdout | -| `history --dump logs.json` | Write the full JSON response to a file for offline analysis | -| `history --from-file logs.json --plot` | Re-plot a previously dumped response without hitting the cloud | +| `history` | Plot the last 7 days of PV, injection and battery with a dual y-axis (watts left, battery % right). Fills the terminal width and ~2/3 of its height (min 30 lines) | +| `history --days 2` | Use a different time window | +| `history --raw` | Dump the full JSON response (with all numeric arrays) to stdout instead of plotting | +| `history --no-plot` | Print the response structure summary instead of plotting | +| `history --dump logs.json` | Also write the full JSON response to a file (plot is still shown) | +| `history --from-file logs.json` | Re-plot a previously dumped response without hitting the cloud | ### Example output diff --git a/pyproject.toml b/pyproject.toml index 3f45fcf..a069d40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "solmate-optimizer" -version = "0.5.1" +version = "0.6.0" description = "Dynamically adjusts EET SolMate injection profile based on hourly electricity price and weather forecast" readme = "README.md" authors = [ diff --git a/src/solmate_optimizer/history.py b/src/solmate_optimizer/history.py index 98b81ab..38cdcf4 100644 --- a/src/solmate_optimizer/history.py +++ b/src/solmate_optimizer/history.py @@ -1,9 +1,20 @@ """Read-only SolMate history: explore recent logs (PV, injection, battery over time). -The SolMate cloud API exposes a `logs` route (via `solmate_sdk.SolMateAPIClient.get_recent_logs`) -that returns time-series data for the last N days. The exact response shape is not documented; -this command fetches the data, optionally prints a structure summary, and can plot the three -time series (PV power, injection, battery state) in a single ASCII chart with a shared time axis. +Response shape (observed from `SolMateAPIClient.get_recent_logs`): + + {"logs": [ { + "start": iso, "end": iso, "resolution": int, + "timestamp": [iso, ...], # N points + "pv_power": [float, ...], # total PV, W + "pv_power_1": [float, ...], # per-string (unused here) + "pv_power_2": [float, ...], + "inject_power": [float, ...], # grid injection, W + "battery_state":[float, ...], # SoC (fraction or %) + "battery_flow": [float, ...], # charge/discharge, W (unused here) + }, ... ]} + +One bucket per day; arrays inside a bucket are aligned by index. Buckets are +concatenated in response order. """ import datetime @@ -17,13 +28,6 @@ from solmate_optimizer.main import connect_solmate -# Field-name candidates for each metric. The SolMate cloud schema is undocumented, -# so the extractor tries these in order. First match wins. -PV_KEYS = ("pv_power", "pv", "production", "solar", "pv_w") -INJECT_KEYS = ("inject_power", "injection", "inject", "grid_injection", "inject_w") -BATTERY_KEYS = ("battery_state", "battery", "soc", "battery_percentage") -TIMESTAMP_KEYS = ("timestamp", "time", "ts", "date", "datetime") - def _summarize(value: Any, depth: int = 0, max_depth: int = 3) -> str: """Return a short human-readable description of a JSON-ish value.""" @@ -49,34 +53,6 @@ def _summarize(value: Any, depth: int = 0, max_depth: int = 3) -> str: return f"{type(value).__name__}({value!r})" -def _find_sample_list(data: Any) -> list[dict] | None: - """Walk a nested response and return the first list of dicts that looks like samples. - - Heuristic: a sample dict contains at least one timestamp-like key and at least one - of the PV/injection/battery keys. - """ - if isinstance(data, list) and data and isinstance(data[0], dict): - sample = data[0] - keys = set(sample.keys()) - has_ts = any(k in keys for k in TIMESTAMP_KEYS) - has_metric = any(k in keys for k in PV_KEYS + INJECT_KEYS + BATTERY_KEYS) - if has_ts or has_metric: - return data - if isinstance(data, dict): - for value in data.values(): - found = _find_sample_list(value) - if found: - return found - return None - - -def _pick(sample: dict, candidates: tuple[str, ...]) -> str | None: - for key in candidates: - if key in sample: - return key - return None - - def _parse_timestamp(value: Any) -> datetime.datetime | None: if isinstance(value, (int, float)): # Heuristic: > 1e12 → milliseconds, otherwise seconds @@ -91,39 +67,46 @@ def _parse_timestamp(value: Any) -> datetime.datetime | None: def extract_series(data: Any) -> dict[str, list] | None: - """Extract (timestamps, pv, injection, battery) from a recent-logs response. + """Flatten the columnar recent-logs response into aligned per-point lists. - Returns a dict with keys "t", "pv", "inject", "battery" — each a list of the same length. - Missing metrics are filled with None; the caller can decide whether to plot them. - Returns None if no sample list could be found. + Returns {"t", "pv", "inject", "battery"} with equal-length lists, or None if + the response has no usable `logs` bucket. """ - samples = _find_sample_list(data) - if not samples: + if not isinstance(data, dict): + return None + buckets = data.get("logs") + if not isinstance(buckets, list) or not buckets: return None - - ts_key = _pick(samples[0], TIMESTAMP_KEYS) - pv_key = _pick(samples[0], PV_KEYS) - inj_key = _pick(samples[0], INJECT_KEYS) - bat_key = _pick(samples[0], BATTERY_KEYS) t: list[datetime.datetime] = [] pv: list[float | None] = [] inject: list[float | None] = [] battery: list[float | None] = [] - for s in samples: - parsed_ts = _parse_timestamp(s.get(ts_key)) if ts_key else None - if parsed_ts is None: + def _at(arr: Any, i: int) -> float | None: + if isinstance(arr, list) and i < len(arr): + return arr[i] + return None + + for bucket in buckets: + if not isinstance(bucket, dict): continue - t.append(parsed_ts) - pv.append(s.get(pv_key) if pv_key else None) - inject.append(s.get(inj_key) if inj_key else None) - battery.append(s.get(bat_key) if bat_key else None) + timestamps = bucket.get("timestamp") or [] + pv_arr = bucket.get("pv_power") + inj_arr = bucket.get("inject_power") + bat_arr = bucket.get("battery_state") + for i, ts in enumerate(timestamps): + parsed = _parse_timestamp(ts) + if parsed is None: + continue + t.append(parsed) + pv.append(_at(pv_arr, i)) + inject.append(_at(inj_arr, i)) + battery.append(_at(bat_arr, i)) - return { - "t": t, "pv": pv, "inject": inject, "battery": battery, - "keys": {"timestamp": ts_key, "pv": pv_key, "inject": inj_key, "battery": bat_key}, - } + if not t: + return None + return {"t": t, "pv": pv, "inject": inject, "battery": battery} def _drop_nones(xs: list, ys: list) -> tuple[list, list]: @@ -134,11 +117,10 @@ def _drop_nones(xs: list, ys: list) -> tuple[list, list]: def plot_history(series: dict[str, list], max_watts: float = 800.0) -> None: - """Plot PV, injection, and battery on a single time-axis chart. + """Plot PV, injection, and battery with a dual y-axis (watts left, percent right). - PV and injection are plotted in watts (left axis range 0..max_watts). Battery is - normalized to the same scale as 0..100 % * max_watts / 100, so the battery curve - sweeps the same vertical space. The y-axis ticks label both views. + Size: terminal width × max(2/3 of terminal height, 30 lines). + Battery values 0–1 are treated as fractions and scaled to percent. """ t = series["t"] if not t: @@ -148,51 +130,51 @@ def plot_history(series: dict[str, list], max_watts: float = 800.0) -> None: plt.clf() plt.date_form("Y-m-d H:M") date_strings = plt.datetimes_to_strings(t, output_form="Y-m-d H:M") - plt.plotsize(100, 20) - plt.title("SolMate history: PV (orange), injection (blue), battery (green, %)") + plt.plotsize(plt.tw(), max(int(plt.th() * 2 / 3), 30)) + plt.title("SolMate history — PV (orange), injection (cyan), battery (green)") pv_t, pv_y = _drop_nones(date_strings, series["pv"]) inj_t, inj_y = _drop_nones(date_strings, series["inject"]) bat_t, bat_y_raw = _drop_nones(date_strings, series["battery"]) - # Battery may be fraction 0–1 or percent 0–100. Detect and normalize to percent. + # Battery may be fraction 0–1 or percent 0–100. Normalize to percent. if bat_y_raw and max(bat_y_raw) <= 1.5: bat_pct = [v * 100 for v in bat_y_raw] else: bat_pct = list(bat_y_raw) - # Scale percent to watts-axis so all three curves share the same vertical range. - bat_y = [v * max_watts / 100 for v in bat_pct] if pv_y: - plt.plot(pv_t, pv_y, color="orange", label="PV (W)") + plt.plot(pv_t, pv_y, color="orange", label="PV (W)", yside="left") if inj_y: - plt.plot(inj_t, inj_y, color="blue+", label="injection (W)") - if bat_y: - plt.plot(bat_t, bat_y, color="green", label="battery (% scaled)") + plt.plot(inj_t, inj_y, color="cyan+", label="injection (W)", yside="left") + if bat_y_raw: + plt.plot(bat_t, bat_pct, color="green+", label="battery (%)", yside="right") - plt.ylim(0, max_watts) - # Y ticks show watts and equivalent battery percentage side by side. + plt.ylim(0, max_watts, yside="left") + plt.ylim(0, 100, yside="right") watt_ticks = [0, max_watts * 0.25, max_watts * 0.5, max_watts * 0.75, max_watts] - tick_labels = [f"{int(w)}W / {int(w * 100 / max_watts)}%" for w in watt_ticks] - plt.yticks(watt_ticks, tick_labels) + plt.yticks(watt_ticks, [f"{int(w)}" for w in watt_ticks], yside="left") + plt.yticks([0, 20, 40, 60, 80, 100], ["0", "20", "40", "60", "80", "100"], yside="right") + plt.ylabel("watts", yside="left") + plt.ylabel("battery %", yside="right") plt.xlabel("time") plt.show() @click.command() -@click.option("--days", type=int, default=1, help="Number of days of history to fetch (default: 1)") -@click.option("--raw", is_flag=True, help="Dump full JSON response to stdout") +@click.option("--days", type=int, default=7, help="Number of days of history to fetch (default: 7)") +@click.option("--raw", is_flag=True, help="Dump full JSON response to stdout and skip plotting") @click.option("--dump", "dump_path", type=click.Path(dir_okay=False, writable=True), - default=None, help="Write full JSON response to this file") -@click.option("--plot", "show_plot", is_flag=True, - help="Render an ASCII plot with PV, injection and battery over time") + default=None, help="Write full JSON response to this file (plot is still shown)") +@click.option("--no-plot", "no_plot", is_flag=True, + help="Skip the ASCII plot (print the response structure summary only)") @click.option("--from-file", "from_file", type=click.Path(exists=True, dir_okay=False, readable=True), default=None, help="Load response from a JSON file instead of the cloud (for offline analysis)") @click.option("--max-watts", type=float, default=800.0, envvar="MAX_WATTS", help="SolMate max injection capacity in watts (for plot scaling)") -def history(days: int, raw: bool, dump_path: str | None, show_plot: bool, +def history(days: int, raw: bool, dump_path: str | None, no_plot: bool, from_file: str | None, max_watts: float): - """Fetch recent logs (PV, injection, battery) from the SolMate cloud and summarize them.""" + """Fetch recent logs (PV, injection, battery) from the SolMate cloud and plot them.""" if from_file: with open(from_file, "r", encoding="utf-8") as fh: data = json.load(fh) @@ -228,20 +210,18 @@ def history(days: int, raw: bool, dump_path: str | None, show_plot: bool, click.echo(json.dumps(data, indent=2, default=str)) return - click.echo("\nResponse structure:") - click.echo(_summarize(data)) - - if show_plot: - series = extract_series(data) - if series is None: - click.echo( - "\nCould not locate a sample list in the response. " - "Run with --raw or --dump FILE and inspect the shape, " - "then extend PV_KEYS / INJECT_KEYS / BATTERY_KEYS in history.py.", - err=True, - ) - sys.exit(2) - mapped = series["keys"] - click.echo(f"\nMapped keys: {mapped}") - click.echo(f"Samples: {len(series['t'])}") - plot_history(series, max_watts=max_watts) + if no_plot: + click.echo("\nResponse structure:") + click.echo(_summarize(data)) + return + + series = extract_series(data) + if series is None: + click.echo( + "\nNo `logs` bucket with usable timestamps in the response. " + "Run with --raw or --dump FILE and inspect the shape.", + err=True, + ) + sys.exit(2) + click.echo(f"Samples: {len(series['t'])}") + plot_history(series, max_watts=max_watts) diff --git a/uv.lock b/uv.lock index e6d20f7..364b98c 100644 --- a/uv.lock +++ b/uv.lock @@ -236,7 +236,7 @@ wheels = [ [[package]] name = "solmate-optimizer" -version = "0.5.1" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "click" }, From 180c8addee59c05dd8835f13046c9df6d88460ec Mon Sep 17 00:00:00 2001 From: Harald Schilly Date: Tue, 21 Apr 2026 14:35:02 +0200 Subject: [PATCH 4/4] README: add history screenshot Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 6 ++++++ docs/solmate-history.png | Bin 0 -> 24517 bytes 2 files changed, 6 insertions(+) create mode 100644 docs/solmate-history.png diff --git a/README.md b/README.md index f087363..0242c0d 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,12 @@ When using `uvx`, prefix every command with `uvx --from solmate-optimizer@latest | `history --dump logs.json` | Also write the full JSON response to a file (plot is still shown) | | `history --from-file logs.json` | Re-plot a previously dumped response without hitting the cloud | +### `history` screenshot + +`solmate history` renders the last 7 days of PV production, grid injection and battery state on a dual-axis ASCII chart that fills the terminal: + +![solmate history — 7 days of PV, injection, battery](docs/solmate-history.png) + ### Example output ``` diff --git a/docs/solmate-history.png b/docs/solmate-history.png new file mode 100644 index 0000000000000000000000000000000000000000..2879dca58f0a931f986ccd3ee95c347e70525ee4 GIT binary patch literal 24517 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 c9u_FnDE