diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd5590b --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +node_modules/ +.npmrc +.env +.env.* +__tests__/ +coverage/ +.nyc_output/ +dist/ +build/ +.cache/ +*.log +.DS_Store +tmp/ +.tmp/ +__pycache__/ +*.pyc +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +READY_TO_PUBLISH diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6faac56 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,25 @@ +# SolarSphere Offline-First MVP Agents + +This repository contains a production-oriented Python MVP for SolarSphere, an offline-first geospatial visualization scaffold. The code focuses on data contracts, a pluggable data-source adapter surface, deterministic delta reconciliation, and a lightweight render summary suitable for testing and extension. + +Architecture overview +- Core data primitives: Asset, TelemetryPoint, WeatherLayer, Event. +- Data sources: pluggable adapters (CSV, REST, MQTT, etc.). CSVAdapter is implemented for MVP. +- Delta reconciliation: deterministic, replay-friendly merge of local and remote deltas. +- Lightweight renderer: builds a serializable snapshot suitable for downstream visualization without a browser runtime. +- Packaging: Python package under idea78_solarsphere_offline_first with a small, exportable API. + +Tech stack decisions +- Language: Python 3.9+ for rapid prototyping, strong typing, and easy packaging. +- No external GUI dependencies yet; a JSON-friendly render path is provided to support future WebGL/Canvas rendering layers. +- Tests: pytest-based unit tests ensure data models, adapters, and delta logic behave deterministically. + +Testing and commands +- Run tests locally: ./test.sh +- Build package: python3 -m build +- Inspect package: pytest tests -q + +Contributing rules +- Add small, well-scoped changes; prefer minimal surface area. +- Update AGENTS.md if you add new architecture or data contracts. +- Ensure tests cover new behavior. diff --git a/README.md b/README.md index 23f73ff..2bd893b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,30 @@ -# idea78-solarsphere-offline-first +# SolarSphere Offline-First MVP -Source logic for Idea #78 \ No newline at end of file +Overview +- A Python-based MVP for offline-first geospatial visualization and telemetry replay. Focuses on simple, well-defined data contracts and deterministic delta reconciliation that can run entirely offline with optional data-sync when connectivity returns. + +What’s included +- Core data primitives: Asset, TelemetryPoint, WeatherLayer, Event +- CSV-based telemetry adapter for MVP data ingestion +- Delta reconciliation utilities for deterministic merges +- Lightweight render summary producing JSON suitable for downstream tooling +- Minimal CLI and a test suite to validate core behavior + +Getting started +- Install dependencies and run tests: `bash test.sh` +- Package: `python3 -m build` (also in test script) + +Project structure (high level) +- src/idea78_solarsphere_offline_first/: Python package with core models, adapters, delta logic, and rendering helpers +- tests/: Unit tests for models, delta logic, and adapters +- AGENTS.md: Architecture and contribution guidance +- test.sh: Test runner that executes tests and packaging checks + +Usage example (quick) +- Load a CSV telemetry file via CSVAdapter, reconcile with a local delta, and render a snapshot: + - adapter = CSVAdapter("telemetry.csv") + - telem = adapter.load_telemetry() + - snapshot = render.render_snapshot([asset], telem, weather, events) + - print(snapshot) + +This repository adheres to the contribution guidelines described in AGENTS.md and is designed to be extended in sprint fashion. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..965af01 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "idea78_solarsphere_offline_first" +version = "0.1.0" +description = "Offline-first geospatial visualization scaffolding for SolarSphere MVP" +readme = "README.md" +requires-python = ">=3.9" +dependencies = [] + +[tool.setuptools] +package-dir = {"" = "src"} + +# Removed legacy setuptools.find block to ensure compatibility with current build backends diff --git a/src/idea78_solarsphere_offline_first/__init__.py b/src/idea78_solarsphere_offline_first/__init__.py new file mode 100644 index 0000000..7d06195 --- /dev/null +++ b/src/idea78_solarsphere_offline_first/__init__.py @@ -0,0 +1,5 @@ +"""Idea78 SolarSphere Offline-First package initializer""" + +__version__ = "0.1.0" + +from .core import Asset, TelemetryPoint, WeatherLayer, Event # re-export core types diff --git a/src/idea78_solarsphere_offline_first/core.py b/src/idea78_solarsphere_offline_first/core.py new file mode 100644 index 0000000..1ade642 --- /dev/null +++ b/src/idea78_solarsphere_offline_first/core.py @@ -0,0 +1,55 @@ +"""Core data primitives for SolarSphere offline-first MVP.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List, Dict +import json + + +@dataclass +class Asset: + site_id: str + location: Dict[str, float] # e.g., {"lat": 12.34, "lon": 56.78} + layers: List[str] = field(default_factory=list) + version: int = 1 + + def to_json(self) -> str: + return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True) + + +@dataclass +class TelemetryPoint: + site_id: str + ts: int # epoch milliseconds + lat: float + lon: float + metric: str + value: float + quality: str = "good" + + def to_json(self) -> str: + return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True) + + +@dataclass +class WeatherLayer: + region: str + ts: int + param: str + value: float + source: str + + def to_json(self) -> str: + return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True) + + +@dataclass +class Event: + ts: int + site_id: str + type: str + description: str + severity: str + + def to_json(self) -> str: + return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True) diff --git a/src/idea78_solarsphere_offline_first/csv_adapter.py b/src/idea78_solarsphere_offline_first/csv_adapter.py new file mode 100644 index 0000000..cbd0b5b --- /dev/null +++ b/src/idea78_solarsphere_offline_first/csv_adapter.py @@ -0,0 +1,40 @@ +"""CSV-based adapter for ingesting telemetry data (MVP).""" +from __future__ import annotations + +import csv +from typing import List + +from .core import TelemetryPoint + + +class CSVAdapter: + """Simple CSV data adapter for TelemetryPoint ingest. + + Expected CSV header: + site_id,ts,lat,lon,metric,value,quality + """ + + def __init__(self, path: str): + self.path = path + + def load_assets(self) -> List: + # MVP: not implemented for tests; return empty list + return [] + + def load_telemetry(self) -> List[TelemetryPoint]: + pts: List[TelemetryPoint] = [] + with open(self.path, newline="") as f: + reader = csv.DictReader(f) + for row in reader: + pts.append( + TelemetryPoint( + site_id=row["site_id"], + ts=int(row["ts"]), + lat=float(row["lat"]), + lon=float(row["lon"]), + metric=row["metric"], + value=float(row["value"]), + quality=row.get("quality", "good"), + ) + ) + return pts diff --git a/src/idea78_solarsphere_offline_first/delta.py b/src/idea78_solarsphere_offline_first/delta.py new file mode 100644 index 0000000..0c7867c --- /dev/null +++ b/src/idea78_solarsphere_offline_first/delta.py @@ -0,0 +1,35 @@ +"""Deterministic, replay-friendly delta reconciliation helpers.""" +from __future__ import annotations + +from typing import List, Dict, Tuple +from .core import TelemetryPoint + + +def _key(pt: TelemetryPoint) -> Tuple[str, int, str]: + # Unique key for a telemetry point by site, timestamp, and metric + return (pt.site_id, pt.ts, pt.metric) + + +def reconcile_deltas(local: List[TelemetryPoint], remote: List[TelemetryPoint]) -> List[TelemetryPoint]: + """Deterministically merge local and remote telemetry points. + + Remote data takes precedence when keys collide. The function returns a new + list containing the merged view. Order is not guaranteed but deterministic + with respect to the input keys. + """ + merged: Dict[Tuple[str, int, str], TelemetryPoint] = {} + + for p in local: + merged[_key(p)] = p + for p in remote: + merged[_key(p)] = p + + return list(merged.values()) + + +class TelemetryDelta: + def __init__(self, delta: List[TelemetryPoint], ts: int, anchor: str, signature: str): + self.delta = delta + self.ts = ts + self.anchor = anchor + self.signature = signature diff --git a/src/idea78_solarsphere_offline_first/render.py b/src/idea78_solarsphere_offline_first/render.py new file mode 100644 index 0000000..0193ac3 --- /dev/null +++ b/src/idea78_solarsphere_offline_first/render.py @@ -0,0 +1,18 @@ +"""Lightweight render summary for offline-first MVP. + +This is intentionally data-centric (JSON-friendly) to support downstream +visualization layers without a browser runtime in this MVP. +""" +from __future__ import annotations + +from typing import List +from .core import Asset, TelemetryPoint, WeatherLayer, Event + + +def render_snapshot(assets: List[Asset], telemetry: List[TelemetryPoint], weather: List[WeatherLayer], events: List[Event]) -> dict: + return { + "assets": [a.__dict__ for a in assets], + "telemetry_count": len(telemetry), + "weather_count": len(weather), + "events": len(events), + } diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..7f29ad2 --- /dev/null +++ b/test.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Basic test suite and packaging verification for SolarSphere MVP +echo "Running tests..." +pytest -q + +echo "Building package..." +python3 -m build + +echo "All tests passed and package built." diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c7ac40f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +import os +import sys + +# Ensure the local src package is importable by pytest +SRC_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")) +if SRC_PATH not in sys.path: + sys.path.insert(0, SRC_PATH) diff --git a/tests/test_csv_adapter.py b/tests/test_csv_adapter.py new file mode 100644 index 0000000..f80c1b0 --- /dev/null +++ b/tests/test_csv_adapter.py @@ -0,0 +1,20 @@ +import tempfile +import os +from idea78_solarsphere_offline_first.csv_adapter import CSVAdapter +from idea78_solarsphere_offline_first.core import TelemetryPoint + + +def test_csv_adapter_loads_telemetry_points(): + with tempfile.NamedTemporaryFile(mode="w+", delete=False, suffix=".csv") as tf: + tf.write("site_id,ts,lat,lon,metric,value,quality\n") + tf.write("siteA,1000,1.0,2.0,temperature,25.0,good\n") + tf.flush() + path = tf.name + + adapter = CSVAdapter(path) + pts = adapter.load_telemetry() + assert isinstance(pts, list) and len(pts) == 1 + p = pts[0] + assert isinstance(p, TelemetryPoint) + assert p.site_id == "siteA" and p.metric == "temperature" and p.value == 25.0 + os.unlink(path) diff --git a/tests/test_delta.py b/tests/test_delta.py new file mode 100644 index 0000000..312a169 --- /dev/null +++ b/tests/test_delta.py @@ -0,0 +1,10 @@ +from idea78_solarsphere_offline_first.core import TelemetryPoint +from idea78_solarsphere_offline_first.delta import reconcile_deltas + + +def test_reconcile_deltas_basic_override(): + local = [TelemetryPoint(site_id="S1", ts=1000, lat=0.0, lon=0.0, metric="temp", value=20.0)] + remote = [TelemetryPoint(site_id="S1", ts=1000, lat=0.0, lon=0.0, metric="temp", value=22.5)] + merged = reconcile_deltas(local, remote) + assert len(merged) == 1 + assert merged[0].value == 22.5 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..5be3ac6 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,18 @@ +from idea78_solarsphere_offline_first.core import Asset, TelemetryPoint, WeatherLayer, Event + + +def test_asset_dataclass(): + a = Asset(site_id="site-01", location={"lat": 12.34, "lon": 56.78}, layers=["terrain", "overlay"], version=1) + assert a.site_id == "site-01" + assert isinstance(a.layers, list) and a.layers[0] == "terrain" + + +def test_telemetry_point_dataclass(): + t = TelemetryPoint(site_id="site-01", ts=1234567890, lat=12.3, lon=45.6, metric="temp", value=22.5) + assert t.metric == "temp" and t.value == 22.5 + + +def test_weather_event_dataclass(): + w = WeatherLayer(region="region-1", ts=1000, param="wind", value=5.4, source="sim") + e = Event(ts=1000, site_id="site-01", type="maintenance", description="routine check", severity="low") + assert w.param == "wind" and e.severity == "low"