diff --git a/skills/battery-lifecycle-observer/skill.yaml b/skills/battery-lifecycle-observer/skill.yaml index 2bf18ac..83b44bb 100644 --- a/skills/battery-lifecycle-observer/skill.yaml +++ b/skills/battery-lifecycle-observer/skill.yaml @@ -18,6 +18,8 @@ sensors_required: protocol: growatt - type: victron_grid_power protocol: victron + - type: battery + protocol: simulation triggers: - name: battery_deep_discharge_risk @@ -32,6 +34,14 @@ triggers: escalate_to: local_slm action_tier: A + # Tier D — Critical SOC emergency cutoff: prevent deep discharge damage + # Covers simulation (sensor_type='battery'), Growatt, Victron, and hook-derived is_soc_sensor. + - name: battery_emergency_cutoff + condition: "(sensor_type == 'battery' or sensor_type == 'growatt_battery_soc' or sensor_type == 'victron_battery_soc' or is_soc_sensor == 1) and value < battery_critical_threshold" + bypass_llm: true + action_tier: D + cooldown_seconds: 0 + - name: olax_voltage_decay_degraded condition: "is_voltage_mode == 1 and is_battery_voltage_sensor == 1 and outage_active == 1 and outage_duration_minutes >= olax_min_outage_minutes and voltage_decay_v_per_hour >= olax_decay_threshold_v_per_hour" cooldown_seconds: 1800 @@ -53,6 +63,13 @@ prompts: Do not include timestamps, percentages, units, currency, or action statements. Example: "Your battery has been cycling heavily this week, which can speed up wear." + battery_emergency_cutoff: | + Your job is diagnosis only. + Write one plain sentence for a non-technical building owner explaining why + the battery was cut off before full discharge. + Do not include timestamps, percentages, units, currency, or action statements. + Example: "The backup battery dropped critically low, so Ori cut it off to prevent permanent damage." + olax_voltage_decay_degraded: | Your job is diagnosis only. Write one plain sentence for a non-technical building owner explaining why @@ -66,10 +83,13 @@ actions: tier: A - name: log_to_dashboard tier: A + - name: switch_to_grid + tier: D defaults: battery_deep_discharge_risk: [alert_whatsapp, log_to_dashboard] battery_cycle_stress: [alert_whatsapp, log_to_dashboard] olax_voltage_decay_degraded: [alert_whatsapp, log_to_dashboard] + battery_emergency_cutoff: [switch_to_grid, alert_whatsapp, log_to_dashboard] config: timezone: Africa/Lagos @@ -80,6 +100,9 @@ config: low_soc_persistence_minutes: 30 efc_warning_threshold_weekly: 7.0 + # Emergency cutoff threshold (Tier D) + battery_critical_threshold: 5.0 + # Voltage-proxy mode (PoC OLAX UPS path) battery_voltage_sensor_ids: ["olax-battery-voltage"] grid_voltage_sensor_ids: ["grid-voltage"] diff --git a/skills/energy-anomaly-detector/skill.yaml b/skills/energy-anomaly-detector/skill.yaml index 1cda1b2..6fc23ec 100644 --- a/skills/energy-anomaly-detector/skill.yaml +++ b/skills/energy-anomaly-detector/skill.yaml @@ -11,6 +11,8 @@ sensors_required: protocol: i2c - type: current protocol: serial + - type: voltage + protocol: serial triggers: - name: sustained_overdraw @@ -32,11 +34,27 @@ triggers: action_tier: A - name: dangerous_overcurrent - condition: "value > dangerous_overcurrent_threshold" + condition: "(sensor_type == 'current_clamp' or sensor_type == 'ads1115_current' or sensor_type == 'current') and value > dangerous_overcurrent_threshold" + bypass_llm: true + action_tier: D + cooldown_seconds: 0 + + # Tier D — Grid overvoltage: protect equipment from damaging voltage levels + - name: voltage_overvoltage + condition: "sensor_type == 'voltage' and sensor_id == 'grid-main_voltage' and value > overvoltage_threshold" bypass_llm: true action_tier: D cooldown_seconds: 0 + # Tier B — PHCN grid restored: switch load back from generator to grid + # phcn_restore_active is injected by hooks when load is confirmed on generator. + # Default is 0 (safe for deployments without live context). + - name: grid_phcn_restored + condition: "sensor_type == 'voltage' and sensor_id == 'grid-main_voltage' and value >= grid_restore_voltage and phcn_restore_active == 1" + action_tier: B + reasoning_policy: post_action + cooldown_seconds: 0 + prompts: sustained_overdraw: | Your job is diagnosis only. @@ -74,12 +92,32 @@ prompts: Example: "A dangerous power surge was detected and this can damage equipment quickly." + voltage_overvoltage: | + Your job is diagnosis only. + Write one plain sentence for a non-technical building owner explaining why + a sudden voltage spike is dangerous to connected equipment. + Do not include timestamps, currency, percentages, units, or action statements. + Example: "An unusually high voltage arrived from the grid and can damage sensitive electronics." + + grid_phcn_restored: | + Your job is diagnosis only. + Write one plain sentence for a non-technical building owner explaining why + Ori switched the building back to grid power. + Do not include timestamps, currency, percentages, units, or action statements. + Example: "PHCN power returned to normal levels, so Ori switched the building back to grid supply." + actions: available: - name: alert_whatsapp tier: A - name: log_to_dashboard tier: A + # Tier D relay actions: override in deployment config after wiring is verified. + # See dangerous_overcurrent default comment below. + - name: switch_to_generator + tier: D + - name: switch_to_grid + tier: B defaults: sustained_overdraw: [alert_whatsapp, log_to_dashboard] sudden_load_spike: [alert_whatsapp, log_to_dashboard] @@ -92,6 +130,8 @@ actions: # after relay wiring is physically verified and mapped in `actions.relay`. # Example override: [trip_relay, alert_whatsapp, log_to_dashboard] dangerous_overcurrent: [alert_whatsapp, log_to_dashboard] + voltage_overvoltage: [switch_to_generator, alert_whatsapp, log_to_dashboard] + grid_phcn_restored: [switch_to_grid, alert_whatsapp, log_to_dashboard] config: min_quality: 0.8 @@ -110,3 +150,8 @@ config: # line_voltage: 230.0 power_factor: 0.9 dangerous_overcurrent_threshold: 20.0 + # Grid voltage triggers + overvoltage_threshold: 260.0 + grid_restore_voltage: 200.0 + # Injected to 1 by hooks when load is confirmed on generator; 0 = safe default + phcn_restore_active: 0 diff --git a/skills/hvac-refrigerant-monitor/skill.yaml b/skills/hvac-refrigerant-monitor/skill.yaml index deb71fe..f31115d 100644 --- a/skills/hvac-refrigerant-monitor/skill.yaml +++ b/skills/hvac-refrigerant-monitor/skill.yaml @@ -15,6 +15,8 @@ sensors_required: protocol: i2c - type: current_clamp protocol: i2c + - type: current + protocol: serial triggers: - name: gas_leak_detected @@ -35,6 +37,18 @@ triggers: escalate_to: local_slm action_tier: A + # Tier C — Compressor overload against a known baseline: requires operator approval. + # baseline_current and compressor_fault_active are injected by hooks at commissioning + # after the technician measures and records the unit's normal draw. + # sensor_id guard is needed because the demo evaluates all current readings against + # this skill; in production only HVAC sensors are routed here. + - name: compressor_overload + condition: "(sensor_type == 'current_clamp' or sensor_type == 'current') and sensor_id == 'ac-01_compressor_current' and compressor_fault_active == 1 and value >= baseline_current * compressor_overload_ratio" + action_tier: C + requires_approval: true + escalate_to: rule + cooldown_seconds: 0 + prompts: gas_concentration: | Gas concentration reading: {value} ppm @@ -46,6 +60,13 @@ prompts: What immediate checks should the HVAC technician perform? Answer in 2-3 plain sentences. + compressor_overload: | + Your job is diagnosis only. + Write one plain sentence for a non-technical building owner explaining why + the AC compressor current is elevated and what it may indicate. + Do not include timestamps, currency, percentages, units, or action statements. + Example: "The air conditioner compressor is drawing more power than expected, which can signal mechanical wear." + current_clamp: | Compressor current reading: {value} A 24-hour baseline: {history.avg_24h('load_current')} A @@ -71,3 +92,10 @@ actions: gas_leak_detected: [close_gas_valve, alert_whatsapp, alert_sms, log_to_dashboard] gas_concentration_elevated: [alert_whatsapp, log_to_dashboard] compressor_current_anomaly: [alert_whatsapp, log_to_dashboard] + compressor_overload: [alert_whatsapp, log_to_dashboard] + +config: + # compressor_overload trigger — set by hooks at commissioning after measuring baseline + compressor_fault_active: 0 + baseline_current: 5.0 + compressor_overload_ratio: 1.4 diff --git a/skills/solar-performance-monitor/skill.yaml b/skills/solar-performance-monitor/skill.yaml index 38fe0d6..8662558 100644 --- a/skills/solar-performance-monitor/skill.yaml +++ b/skills/solar-performance-monitor/skill.yaml @@ -20,6 +20,8 @@ sensors_required: protocol: victron - type: victron_battery_soc protocol: victron + - type: power + protocol: simulation triggers: - name: solar_underperforming_daytime @@ -34,6 +36,16 @@ triggers: escalate_to: local_slm action_tier: A + # Tier A — Direct performance-ratio check for simulation and inverter power sensors. + # expected_output_watts and performance_ratio are forwarded from reading.metadata + # (set by the solar device simulator or real inverter telemetry). + # expected_output_watts: 0.0 default suppresses night-time firing. + - name: solar_output_degraded + condition: "(sensor_type == 'power' or sensor_type == 'growatt_pv_power' or sensor_type == 'victron_pv_power') and expected_output_watts > 0 and performance_ratio < solar_min_performance_ratio" + action_tier: A + escalate_to: local_slm + cooldown_seconds: 0 + - name: unexpected_grid_draw_with_good_sun condition: "(sensor_type == 'growatt_grid_power' or sensor_type == 'victron_grid_power') and quality >= min_quality and config_valid == 1 and is_daytime == 1 and pv_ratio_snapshot >= strong_sun_ratio and grid_import_watts >= grid_draw_threshold_watts" cooldown_seconds: 900 @@ -41,6 +53,13 @@ triggers: action_tier: A prompts: + solar_output_degraded: | + Your job is diagnosis only. + Write one plain sentence for a non-technical building owner explaining why + solar output is below expectation and what typically causes it. + Do not include timestamps, currency, percentages, units, or action statements. + Example: "Your solar panels are producing less power than expected, which is often caused by dust or shading." + solar_underperforming_daytime: | Your job is diagnosis only. Write one plain sentence for a non-technical business owner explaining the @@ -72,6 +91,7 @@ actions: - name: log_to_dashboard tier: A defaults: + solar_output_degraded: [alert_whatsapp, log_to_dashboard] solar_underperforming_daytime: [alert_whatsapp, log_to_dashboard] battery_not_charging_when_pv_available: [alert_whatsapp, log_to_dashboard] unexpected_grid_draw_with_good_sun: [alert_whatsapp, log_to_dashboard] @@ -92,3 +112,7 @@ config: strong_sun_ratio: 0.5 grid_draw_threshold_watts: 500.0 battery_not_full_soc_threshold: 95.0 + # solar_output_degraded trigger — forwarded from reading.metadata per tick + solar_min_performance_ratio: 0.75 + expected_output_watts: 0.0 + performance_ratio: 1.0 diff --git a/tests/test_battery_lifecycle_skill.py b/tests/test_battery_lifecycle_skill.py index ec28cd5..422b12f 100644 --- a/tests/test_battery_lifecycle_skill.py +++ b/tests/test_battery_lifecycle_skill.py @@ -93,8 +93,8 @@ def _ctx(skill, event, store): async def test_skill_loads_with_expected_triggers(): skill = _load_skill() assert skill.name == "battery-lifecycle-observer" - assert len(skill.triggers) == 3 - assert {t.action_tier for t in skill.triggers} == {"A"} + assert len(skill.triggers) == 4 + assert {t.action_tier for t in skill.triggers} == {"A", "D"} @pytest.mark.asyncio diff --git a/tests/test_energy_anomaly_skill.py b/tests/test_energy_anomaly_skill.py index 3de1875..5dbb422 100644 --- a/tests/test_energy_anomaly_skill.py +++ b/tests/test_energy_anomaly_skill.py @@ -113,8 +113,8 @@ def _seed_history(store: _Store, sensor_id: str, values: list[float]) -> None: async def test_skill_loads_with_v2_triggers(): skill = _load_skill() assert skill.name == "energy-anomaly-detector" - assert len(skill.triggers) == 4 - assert {trigger.action_tier for trigger in skill.triggers} == {"A", "D"} + assert len(skill.triggers) == 6 + assert {trigger.action_tier for trigger in skill.triggers} == {"A", "D", "B"} def test_hook_computes_baseline_and_deviation(): diff --git a/tests/test_hvac_skill.py b/tests/test_hvac_skill.py index 0f7b3e3..6d61e9c 100644 --- a/tests/test_hvac_skill.py +++ b/tests/test_hvac_skill.py @@ -19,7 +19,7 @@ def _load_skill(): def test_skill_loads(): skill = _load_skill() assert skill.name == "hvac-refrigerant-monitor" - assert len(skill.triggers) == 3 + assert len(skill.triggers) == 4 def test_gas_leak_trigger_is_tier_d(): diff --git a/tests/test_integration_rule_evaluation.py b/tests/test_integration_rule_evaluation.py index fa2228a..2d21dd1 100644 --- a/tests/test_integration_rule_evaluation.py +++ b/tests/test_integration_rule_evaluation.py @@ -79,7 +79,10 @@ async def test_dangerous_overcurrent_returns_tier_d_with_proof_fields() -> None: assert result.action_tier == "D" assert result.trigger_name == "dangerous_overcurrent" assert result.bypass_llm is True - assert result.proof_rule_condition == "value > dangerous_overcurrent_threshold" + assert ( + result.proof_rule_condition + == "(sensor_type == 'current_clamp' or sensor_type == 'ads1115_current' or sensor_type == 'current') and value > dangerous_overcurrent_threshold" + ) assert result.proof_threshold_name == "dangerous_overcurrent_threshold" assert result.proof_threshold == pytest.approx(20.0) assert result.proof_sensor_value == pytest.approx(52.0) diff --git a/tests/test_solar_performance_skill.py b/tests/test_solar_performance_skill.py index f3770e3..ca3386d 100644 --- a/tests/test_solar_performance_skill.py +++ b/tests/test_solar_performance_skill.py @@ -98,7 +98,7 @@ def _ctx(skill, event, store): async def test_skill_loads_with_expected_triggers(): skill = _load_skill() assert skill.name == "solar-performance-monitor" - assert len(skill.triggers) == 3 + assert len(skill.triggers) == 4 assert {t.action_tier for t in skill.triggers} == {"A"}