From 06adbea0c78ea80e2a5f36542ce381ee57b53b97 Mon Sep 17 00:00:00 2001 From: asobacorp-2 Date: Sun, 15 Mar 2026 14:41:35 -0500 Subject: [PATCH 1/4] Add Higeco OEM transform spec and runtime transformer Add Higeco cloud API (docAPI) as new OEM transform to ODS-E, implementing Phase 4 of platform SEP-019. Includes YAML spec with bearer token auth and 6 endpoints, HigecoTransformer class using normalized contract input pattern, 3 unit tests covering status resolution priority, harness fixture, and documentation updates. --- spec/inverter-api-access.md | 13 ++ src/python/odse/transformer.py | 77 +++++++ src/python/tests/test_transformer_runtime.py | 61 ++++++ tools/transform_harness.py | 15 ++ transforms/higeco-api.yaml | 202 +++++++++++++++++++ 5 files changed, 368 insertions(+) create mode 100644 transforms/higeco-api.yaml diff --git a/spec/inverter-api-access.md b/spec/inverter-api-access.md index 7759a9c..8986d32 100644 --- a/spec/inverter-api-access.md +++ b/spec/inverter-api-access.md @@ -15,6 +15,7 @@ Last reviewed: 2026-02-09 - FIMER: `transforms/fimer-auroravision-api.yaml` - Solis: `transforms/soliscloud-api.yaml` - SolaX: `transforms/solaxcloud-api-v2.yaml` +- Higeco: `transforms/higeco-api.yaml` ## Runtime Support (Python `odse.transform`) @@ -30,6 +31,7 @@ Last reviewed: 2026-02-09 | `fronius` | Implemented | | `sma` | Implemented (normalized contract input) | | `solis`, `soliscloud` | Implemented (normalized contract input) | +| `higeco` | Implemented (normalized contract input) | ## Runtime Verification Harness @@ -98,6 +100,7 @@ Troubleshooting: | FIMER Aurora Vision | Included (Spec) | Cloud API | Aurora Vision account with required role; request API enablement via FIMER support | Vendor-issued credentials per Aurora Vision API docs | | SolisCloud | Included (Spec) | Cloud API | Complete Solis cooperation/application process and receive API activation materials | OAuth2 with AppKey/AppSecret | | SolaX Cloud | Included (Spec) | Cloud API | Generate API token in Solax Cloud third-party ecosystem settings | API token | +| Higeco | Included (Spec) | Cloud API (docAPI) | Obtain API credentials from Higeco for target instance | Bearer token (POST /authenticate) | ## Setup Instructions By OEM @@ -190,6 +193,16 @@ Official references: - https://global.solaxcloud.com/blue/4/user_api/2024/SolaXCloud_User_API_V2.pdf - https://doc.solaxcloud.com/en/inst-w/service/ +### Higeco (Included (Spec)) + +1. Obtain API credentials (username/password or apiToken) from the Higeco instance administrator. +2. Authenticate via POST `https://{instance}.higeco.com/docapi/authenticate` to receive a bearer token. +3. Use the bearer token in subsequent requests to list plants, devices, and retrieve log data. +4. Note the 100,000-sample cap on `log_data` queries; use pagination or narrower time ranges for larger datasets. + +Official references: +- Higeco docAPI endpoint hierarchy is instance-specific; consult your Higeco account representative for documentation access. + ## Implementation Notes For ODS-E - Treat cloud APIs as rate-limited and implement retries with backoff. diff --git a/src/python/odse/transformer.py b/src/python/odse/transformer.py index 4013f0c..c792a08 100644 --- a/src/python/odse/transformer.py +++ b/src/python/odse/transformer.py @@ -81,6 +81,7 @@ def _get_transformer(source: str): "sma": SMATransformer(), "solis": SolisTransformer(), "soliscloud": SolisTransformer(), + "higeco": HigecoTransformer(), "csv": GenericCSVTransformer(), "generic_csv": GenericCSVTransformer(), "generic": GenericCSVTransformer(), @@ -890,6 +891,82 @@ def transform(self, data: Union[str, Path], **kwargs) -> List[Dict[str, Any]]: return out +class HigecoTransformer(BaseTransformer): + """Transform normalized Higeco docAPI records to ODS-E.""" + + CONNECTION_STATUS_MAPPING = { + "CONNECTED": "normal", + "DISCONNECTED": "offline", + } + + POWER_STATUS_MAPPING = { + "ON": "normal", + "OFF": "standby", + "FAULT": "fault", + "WARNING": "warning", + } + + def _resolve_error_type(self, normalized: Dict[str, Any], power_w: Optional[float]) -> str: + conn = str(normalized.get("connectionStatus") or "").upper() + if conn in self.CONNECTION_STATUS_MAPPING: + mapped = self.CONNECTION_STATUS_MAPPING[conn] + if mapped != "normal": + return mapped + + pstat = str(normalized.get("powerStatus") or "").upper() + if pstat in self.POWER_STATUS_MAPPING: + return self.POWER_STATUS_MAPPING[pstat] + + if power_w is not None: + return "standby" if power_w == 0 else "normal" + + return "unknown" + + def transform(self, data: Union[str, Path], **kwargs) -> List[Dict[str, Any]]: + payload = self._parse_json(data) + timezone = kwargs.get("timezone") + interval_hours = (kwargs.get("interval_minutes", 5) or 5) / 60.0 + asset_id = kwargs.get("asset_id") + records_in = _extract_records(payload) + + out: List[Dict[str, Any]] = [] + for r in records_in: + normalized = r.get("normalized") if isinstance(r.get("normalized"), dict) else r + ts = _to_iso8601(normalized.get("timestamp"), timezone=timezone) + if not ts: + continue + + p_w = _to_float(normalized.get("active_power_w")) + e_wh = _to_float(normalized.get("active_energy_wh")) + + kwh = (e_wh / 1000.0) if e_wh is not None else max(((p_w or 0.0) / 1000.0) * interval_hours, 0.0) + error_type = self._resolve_error_type(normalized, p_w) + + rec = _base_record( + timestamp=ts, + kwh=kwh, + error_type=error_type, + error_code=normalized.get("status_code"), + asset_id=asset_id, + ) + if p_w is not None: + rec["kW"] = p_w / 1000.0 + for src, dst in [ + ("voltage_dc_v", "voltage_dc"), + ("current_dc_a", "current_dc"), + ("temperature_c", "temperature"), + ("voltage_v", "voltage_ac"), + ("current_a", "current_ac"), + ("frequency_hz", "frequency"), + ]: + val = _to_float(normalized.get(src)) + if val is not None: + rec[dst] = val + out.append(rec) + + return out + + class GenericCSVTransformer(BaseTransformer): """Transform arbitrary CSV data to ODS-E using a column mapping.""" diff --git a/src/python/tests/test_transformer_runtime.py b/src/python/tests/test_transformer_runtime.py index 4538a0b..ce21f3a 100644 --- a/src/python/tests/test_transformer_runtime.py +++ b/src/python/tests/test_transformer_runtime.py @@ -202,6 +202,67 @@ def test_solis_normalized_mapping(self): self.assertEqual(rows[0]["error_code"], "200") self.assertAlmostEqual(rows[0]["kW"], 4.6) + def test_higeco_normalized_log_data_mapping(self): + payload = """ + { + "records": [ + { + "normalized": { + "timestamp": "2026-03-15T10:00:00Z", + "active_power_w": 5200, + "active_energy_wh": 1300, + "temperature_c": 38.5, + "connectionStatus": "CONNECTED", + "powerStatus": "ON" + } + } + ] + } + """ + rows = transform(payload, source="higeco") + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["error_type"], "normal") + self.assertAlmostEqual(rows[0]["kWh"], 1.3) + self.assertAlmostEqual(rows[0]["kW"], 5.2) + self.assertEqual(rows[0]["temperature"], 38.5) + + def test_higeco_disconnected_maps_offline(self): + payload = """ + { + "records": [ + { + "normalized": { + "timestamp": "2026-03-15T10:00:00Z", + "active_power_w": 0, + "connectionStatus": "DISCONNECTED", + "powerStatus": "OFF" + } + } + ] + } + """ + rows = transform(payload, source="higeco") + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["error_type"], "offline") + + def test_higeco_fault_status_mapping(self): + payload = """ + { + "data": [ + { + "normalized": { + "timestamp": "2026-03-15T10:00:00Z", + "active_power_w": 0, + "powerStatus": "FAULT" + } + } + ] + } + """ + rows = transform(payload, source="higeco") + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["error_type"], "fault") + class EnergyTimeseriesValidationTests(unittest.TestCase): """Tests for the extended energy-timeseries schema fields.""" diff --git a/tools/transform_harness.py b/tools/transform_harness.py index 95f7b71..81844b6 100644 --- a/tools/transform_harness.py +++ b/tools/transform_harness.py @@ -30,6 +30,7 @@ "fimer", "solis", "solaxcloud", + "higeco", ] FIXTURES: Dict[str, str] = { @@ -100,6 +101,20 @@ } ] }), + "higeco": json.dumps({ + "records": [ + { + "normalized": { + "timestamp": "2026-03-15T10:00:00Z", + "active_power_w": 5200, + "active_energy_wh": 1300, + "temperature_c": 38.5, + "connectionStatus": "CONNECTED", + "powerStatus": "ON", + } + } + ] + }), "solaxcloud": json.dumps({ "success": True, "code": 0, diff --git a/transforms/higeco-api.yaml b/transforms/higeco-api.yaml new file mode 100644 index 0000000..cf3b7c1 --- /dev/null +++ b/transforms/higeco-api.yaml @@ -0,0 +1,202 @@ +# Higeco docAPI to ODS-E Transform Specification +# License: CC-BY-SA 4.0 +# Source: Higeco cloud platform (instance-specific docAPI) + +transform: + name: higeco-api + version: "1.0" + oem: Higeco + description: Transform Higeco docAPI telemetry data to ODS-E format + +input_schema: + format: json + api_base: "https://{instance}.higeco.com/docapi/" + authentication: + type: bearer_token + flow: | + POST /authenticate with {"username": "...", "password": "..."} + or {"apiToken": "..."} to obtain bearer token. + header: "Authorization: Bearer {token}" + + endpoints: + authenticate: + path: "/authenticate" + method: POST + body: + username: string + password: string + # Alternative: apiToken: string + response: + token: string + + plants: + path: "/plants" + method: GET + response: + - id: integer + name: string + timezone: string + + plant_devices: + path: "/plants/{plantId}/devices" + method: GET + response: + - id: integer + name: string + type: string + + log_items: + path: "/devices/{deviceId}/logitems" + method: GET + description: Lists available log item keys for a device + response: + - key: string + name: string + unit: string + + log_data: + path: "/devices/{deviceId}/logdata" + method: GET + params: + from: datetime # ISO 8601 + to: datetime # ISO 8601 + keys: string # Comma-separated log item keys + description: | + Returns sampled log data. Maximum 100,000 samples per request. + Exceeding this cap silently truncates results. + response: + data: + - timestamp: datetime + values: + "{key}": float + + last_value: + path: "/devices/{deviceId}/lastvalue" + method: GET + params: + keys: string + response: + data: + - key: string + value: float + timestamp: datetime + + node_log_data: + path: "/nodes/{nodeId}/logdata" + method: GET + params: + from: datetime + to: datetime + keys: string + granularity: string # 5min, 15min, 1h, 1d + description: Aggregated log data at specified granularity + response: + data: + - timestamp: datetime + values: + "{key}": float + +output_mapping: + from_normalized_contract: + timestamp: + source: input.normalized.timestamp + transform: to_iso8601 + description: ISO 8601 timestamp from log data + kWh: + source: input.normalized.active_energy_wh + transform: divide_by_1000 + fallback: + function: calculate_interval_energy + inputs: [input.normalized.active_power_w, interval_hours] + description: Energy from Wh reading, or calculated from power * interval + kW: + source: input.normalized.active_power_w + transform: divide_by_1000 + voltage_dc: + source: input.normalized.voltage_dc_v + unit: V + current_dc: + source: input.normalized.current_dc_a + unit: A + temperature: + source: input.normalized.temperature_c + unit: C + voltage_ac: + source: input.normalized.voltage_v + unit: V + current_ac: + source: input.normalized.current_a + unit: A + frequency: + source: input.normalized.frequency_hz + unit: Hz + error_type: + function: resolve_status + inputs: [input.normalized.connectionStatus, input.normalized.powerStatus, input.normalized.active_power_w] + description: | + Priority: connectionStatus → powerStatus → power-value inference → "unknown" + +status_mapping: + connection_status_to_error_type: + CONNECTED: normal + DISCONNECTED: offline + + power_status_to_error_type: + ON: normal + OFF: standby + FAULT: fault + WARNING: warning + +calculations: + interval_energy: + description: Calculate energy from power reading + formula: "(power_w / 1000.0) * interval_hours" + default_interval: 0.0833 # 5 minutes = 0.0833 hours + + wh_to_kwh: + description: Convert Wh to kWh + formula: "value / 1000.0" + +validation: + physical_bounds: + kWh: + min: 0 + max_formula: "capacity_kw * interval_hours * 1.1" + kW: + min: 0 + max_formula: "capacity_kw * 1.1" + PF: + min: 0 + max: 1 + voltage_ac: + min: 0 + max: 500 + frequency: + min: 45 + max: 65 + temporal: + expected_intervals: ["5min", "15min", "1h", "1d"] + max_gap: "24h" + +api_limits: + log_data: + max_samples: 100000 + description: Requests exceeding 100K samples are silently truncated + node_log_data: + granularities: ["5min", "15min", "1h", "1d"] + description: Server-side aggregation at specified granularity + +notes: | + Higeco docAPI provides SCADA-grade telemetry for inverters and plant-level devices. + The API is instance-specific: each deployment runs at {instance}.higeco.com/docapi/. + + Authentication uses POST /authenticate with credentials or an API token. + Bearer tokens should be refreshed on 401 responses. + + The log_data endpoint has a hard cap of 100,000 samples per request. + For longer time ranges, use node_log_data with aggregation granularity, + or split queries into smaller time windows. + + The normalized contract approach is used because actual API response samples + are not yet available. A future connector will map raw docAPI responses + into the normalized format consumed by HigecoTransformer. From b7cb45c09050c744910f9b0abde49708ed2ffb31 Mon Sep 17 00:00:00 2001 From: asobacorp-2 Date: Sun, 15 Mar 2026 16:55:52 -0500 Subject: [PATCH 2/4] Add Higeco to launch kit support matrix and changelog --- CHANGELOG.md | 3 +++ spec/launch-kit.md | 1 + 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ba5962..42a679c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is inspired by Keep a Changelog and follows semantic versioning. ## [Unreleased] +### Added +- Higeco OEM transform spec, runtime transformer, and harness fixture (Phase 4 of platform SEP-019). + ## [0.4.0] - 2026-02-19 ### Added diff --git a/spec/launch-kit.md b/spec/launch-kit.md index 74adfa7..35e9064 100644 --- a/spec/launch-kit.md +++ b/spec/launch-kit.md @@ -62,6 +62,7 @@ Publish this matrix in docs and outreach posts. | Solis | Yes | Yes | Partner-gated | SolisCloud onboarding | | SolaX | Yes | Yes | Account-required | SolaX tokenId | | Solarman | Yes | Yes | Account/file feed | Logger exports/API | +| Higeco | Yes | Yes | Partner-gated | Higeco docAPI bearer token | ### Consumption & Net Metering Sources (Schema Ready) From afa9c016e9b5484517318551439aae200b1ee2a5 Mon Sep 17 00:00:00 2001 From: asobacorp-2 Date: Sun, 15 Mar 2026 17:28:23 -0500 Subject: [PATCH 3/4] Release v0.5.0 --- CHANGELOG.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42a679c..e5a77b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,26 @@ The format is inspired by Keep a Changelog and follows semantic versioning. ## [Unreleased] +## [0.5.0] - 2026-03-15 + ### Added -- Higeco OEM transform spec, runtime transformer, and harness fixture (Phase 4 of platform SEP-019). +- Higeco OEM transform spec, runtime transformer, and harness fixture (SEP-019 Phase 4). +- Regulatory event normalization contract with unified transform spec and `odse.regulatory` module. +- ERP enrichment JSON schemas: equipment register, equipment ID map, failure taxonomy, maintenance history, spare parts, procurement context, alarm frequency profile. +- ERP enrichment starter notebook with SCADA alarm triage workflow and visualizations. +- IFS Cloud ERP transform spec and alarm frequency computation spec. +- CLI interface (`odse transform`, `odse validate`) with JSON/CSV/Parquet output formats (SEP-015). +- Output serialization module (`odse.io`) with `to_json`, `to_csv`, `to_parquet`, `to_dataframe` (SEP-016). +- Batch validation helper (`odse.validate_batch`) with summary reporting (SEP-018). +- Generic CSV column-mapping transformer (`source="csv"`) with kW-to-kWh fallback (SEP-020). +- SDK usage examples and fixture library: basic transform, batch directory, generic CSV, full pipeline (SEP-019). +- 60-second quickstart guide with sample CSV (SEP-028). +- Sample data fixtures for tutorials and QA: Huawei 24h, Enphase 24h, SolarEdge 24h, generic historian 7d (SEP-037). +- Winter Storm Fern analysis notebook and SMA CSV demo. +- Test scaffold for ERP enrichment schemas (52 tests). + +### Fixed +- Version mismatch: synced `__version__` to 0.4.0 and removed phantom dependencies (SEP-017). ## [0.4.0] - 2026-02-19 From d1c60915bd9559d8fa34a3498c086800fd22e70e Mon Sep 17 00:00:00 2001 From: asobacorp-2 Date: Sun, 15 Mar 2026 17:59:37 -0500 Subject: [PATCH 4/4] Bump package version to 0.5.0 --- src/python/odse/__init__.py | 2 +- src/python/pyproject.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/python/odse/__init__.py b/src/python/odse/__init__.py index 3bded34..2576ed9 100644 --- a/src/python/odse/__init__.py +++ b/src/python/odse/__init__.py @@ -5,7 +5,7 @@ using the ODS-E specification. """ -__version__ = "0.4.0" +__version__ = "0.5.0" from .validator import ( validate, diff --git a/src/python/pyproject.toml b/src/python/pyproject.toml index 6cd661c..e6b453b 100644 --- a/src/python/pyproject.toml +++ b/src/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "odse" -version = "0.4.0" +version = "0.5.0" description = "Open Data Schema for Energy - validation and transformation library" readme = "README.md" license = {text = "Apache-2.0"} @@ -41,7 +41,7 @@ dataframe = [ ] [project.urls] -Homepage = "https://github.com/AsobaCloud/odse" +Homepage = "https://opendataschema.energy" Documentation = "https://opendataschema.energy/docs/" Repository = "https://github.com/AsobaCloud/odse"