Pulse Rate Calculator
Plan for Pulse Rate Calculator
Overview
This document outlines the implementation plan for the Pulse Rate Calculator feature. The goal is to automatically calculate pulse rates by capturing two meter readings at different times and correlating them with pulse counts from Timestream.
Part 1: Backend Implementation
1.1 New Lambda: calculate_pulse_rate_lambda.py
Location: /backend/installer/calculate_pulse_rate_lambda.py
Purpose: Calculate pulse rates by comparing meter readings with Timestream pulse counts.
Required Permissions
Resource | Actions | Purpose |
|---|---|---|
|
| Read device info, write calculated pulse rates |
|
| Query pulse counts for |
|
| Read/update meter readings and timestamps |
API Endpoints
1. Get Pulse Counts - GET /installer/pulse-rate/{devEui}
// Request
{
"devEui": "ABC12" // 5-digit partial DevEui
}
// Response
{
"success": true,
"fullDevEui": "0004A30B00ABC12",
"pulseChannels": {
"P1": {
"pulseCount": 1250,
"firstReading": "2025-12-29T10:00:00Z",
"lastReading": "2025-12-29T14:30:00Z"
},
"P2": {
"pulseCount": 890,
"firstReading": "2025-12-29T10:00:00Z",
"lastReading": "2025-12-29T14:30:00Z"
},
"P3": null // No data or not configured
}
}2. Calculate Pulse Rate - POST /installer/pulse-rate/calculate
// Request
{
"devEui": "ABC12",
"pulseChannel": 1, // 1, 2, or 3
"initialMeterReading": {
"value": 12500.5,
"unit": "gallons",
"timestamp": "2025-12-29T10:00:00Z"
},
"finalMeterReading": {
"value": 12650.3,
"unit": "gallons",
"timestamp": "2025-12-29T14:30:00Z"
}
}
// Response
{
"success": true,
"calculation": {
"meterDelta": 149.8,
"pulseCount": 1498,
"pulseRate": 0.1, // gallons per pulse
"unit": "gallons",
"confidence": "high" // based on time window and pulse count
},
"saved": true
}3. Get Uncaptured Pulse Devices - GET /installer/pulse-rate/uncaptured/{siteId}
// Response
{
"success": true,
"siteId": "SITE123",
"uncapturedDevices": [
{
"devEui": "ABC12",
"fullDevEui": "0004A30B00ABC12",
"deviceModel": "RAK2171",
"pulseChannels": [
{
"channel": 1,
"deviceType": "PWM",
"initialReading": 12500.5,
"initialTimestamp": "2025-12-29T10:00:00Z",
"unit": "gallons",
"hasPulseData": true,
"pulseCountAvailable": 1498
},
{
"channel": 2,
"deviceType": "PGM",
"initialReading": 5000.0,
"initialTimestamp": "2025-12-29T10:00:00Z",
"unit": "ccf",
"hasPulseData": false,
"pulseCountAvailable": 0
}
]
}
]
}Lambda Logic Flow
┌─────────────────────────────────────────────────────────────────────────────┐
│ Calculate Pulse Rate Flow │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Receive request with devEui + finalMeterReading │
│ │ │
│ ▼ │
│ 2. Lookup full DevEui from partial (scan LorawanDevicesTable) │
│ │ │
│ ▼ │
│ 3. Get initial meter reading from InstallerAppDeviceDetails │
│ (stored when installer first submitted the device) │
│ │ │
│ ▼ │
│ 4. Query Timestream for pulse counts between timestamps: │
│ SELECT SUM(measure_value::bigint) as pulse_count │
│ FROM "LorawanTimestreamDBBmsProd"."LorawanRawTableBmsProd" │
│ WHERE DevEui = '{fullDevEui}_P{channel}' │
│ AND time BETWEEN '{initialTimestamp}' AND '{finalTimestamp}' │
│ AND measure_name = 'PulseCount' │
│ │ │
│ ▼ │
│ 5. Calculate pulse rate: │
│ pulseRate = (finalReading - initialReading) / totalPulseCount │
│ │ │
│ ▼ │
│ 6. Save to LorawanDevicesTable: │
│ - PulseScalarValue = pulseRate │
│ - PulseScalarUnit = unit │
│ - PulseRateCapturedAt = timestamp │
│ │ │
│ ▼ │
│ 7. Update InstallerAppDeviceDetails: │
│ - Mark pulse rate as captured │
│ - Store calculation metadata │
│ │
└─────────────────────────────────────────────────────────────────────────────┘1.2 CDK Stack Updates
Location: /devops/cdk/lib/cdk-stack.ts
New Lambda Definition
const installerAppCalculatePulseRateLambda = new lambda.Function(
this,
`${atriaInstallerAppName}CalculatePulseRateLambda`,
{
functionName: `${atriaInstallerAppName}CalculatePulseRateLambda`,
runtime: lambda.Runtime.PYTHON_3_11,
handler: "calculate_pulse_rate_lambda.lambda_handler",
code: lambda.Code.fromAsset("../../backend/installer"),
timeout: cdk.Duration.seconds(30),
memorySize: 256,
role: installerAppCalculatePulseRateLambdaRole,
environment: {
INSTALLER_APP_DEVICE_DETAILS_TABLE: atriaInstallerDeviceDetailsTableName,
INSTALLER_APP_LORAWAN_DEVICES_TABLE: installerAppLorawanDevicesTableName,
TIMESTREAM_DATABASE: "LorawanTimestreamDBBmsProd",
TIMESTREAM_TABLE: "LorawanRawTableBmsProd",
},
}
)API Gateway Integration
Add new routes to the Installer API Gateway:
GET /pulse-rate/{devEui}→ Get pulse countsPOST /pulse-rate/calculate→ Calculate and save pulse rateGET /pulse-rate/uncaptured/{siteId}→ Get devices needing pulse capture
1.3 Migration Lambda Schedule Update
Current Schedule: Once daily at 7:00 AM UTC
New Schedule: Every 15 minutes
// Change from:
schedule: events.Schedule.cron({ minute: "0", hour: "7" })
// Change to:
schedule: events.Schedule.rate(cdk.Duration.minutes(15))Rationale:
After installer clicks "Submit All", devices are saved to
InstallerAppDeviceDetailsMigration lambda creates
_P1,_P2,_P3virtual devices inLorawanDevicesTableLoRaWAN backend starts writing pulse data to Timestream
With 15-minute schedule, pulse data will be available within 15 minutes of submission
Part 2: Frontend Implementation
2.1 Device Form Changes (Pulse Counter Input)
File: /frontend/src/pages/InstallerDeviceFormPage.jsx
Current Behavior
Installer selects device type for each pulse channel
Installer enters "conversion factor" (pulse rate)
Installer enters units
New Behavior
Installer selects device type for each pulse channel
Installer enters initial meter reading (not the rate)
Installer enters units for the meter reading
System captures timestamp when reading is entered
// New pulse counter form fields
{
pulseChannel: 1,
deviceType: "PWM", // Water meter
initialMeterReading: 12500.5, // Current meter value
unit: "gallons", // Unit of the meter reading
timestamp: "2025-12-29T10:00:00Z" // Auto-captured
}UI Changes
┌────────────────────────────────────────────────────────────┐
│ Pulse Counter 1 │
├────────────────────────────────────────────────────────────┤
│ Device Type: [Dropdown: Water Meter, Gas Meter, etc.] │
│ │
│ Current Meter Reading: [ 12500.5 ] │
│ │
│ Units: [Dropdown: gallons, liters, m³, ccf, therms] │
│ │
│ ⓘ The pulse rate will be calculated after installation │
│ when you provide a second meter reading. │
└────────────────────────────────────────────────────────────┘2.2 Installation Submission Changes
File: /frontend/src/services/installationService.js
When submitting device to installation_submission_lambda:
// pulse_data structure changes
const pulseData = {
pulse1: {
deviceType: "PWM",
initialMeterReading: 12500.5,
unit: "gallons",
timestamp: new Date().toISOString(),
// NO scalar/pulseRate - will be calculated later
},
pulse2: {...},
pulse3: {...}
}2.3 Review Page: Uncaptured Pulse Counts Section
File: /frontend/src/pages/InstallerReviewPage.jsx
New Section: "Uncaptured Pulse Counts"
Location: Above "Devices Ready for Installation" section
┌─────────────────────────────────────────────────────────────────────────────┐
│ ⚠️ Uncaptured Pulse Counts (2 devices) [▼] │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ ABC12 - RAK2171 Pulse Counter │ │
│ │ ├─ P1: Water Meter - Initial: 12,500.5 gal @ 10:00 AM │ │
│ │ │ Status: ✅ Pulse data available (1,498 pulses) │ │
│ │ │ [Capture Pulse Rate] │ │
│ │ │ │ │
│ │ ├─ P2: Gas Meter - Initial: 5,000.0 ccf @ 10:00 AM │ │
│ │ │ Status: ⏳ Waiting for pulse data... │ │
│ │ │ [Capture Pulse Rate] (disabled) │ │
│ │ └─ P3: Not configured │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ DEF34 - RAK2171 Pulse Counter │ │
│ │ ├─ P1: Water Meter - Initial: 8,200.0 gal @ 10:15 AM │ │
│ │ │ Status: ⏳ Waiting for pulse data... │ │
│ │ │ [Capture Pulse Rate] (disabled) │ │
│ │ └─ P2, P3: Not configured │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘Component Structure
// New component: UncapturedPulseCountsSection.jsx
<UncapturedPulseCountsSection
siteId={currentSiteId}
onPulseRateCaptured={handlePulseRateCaptured}
/>2.4 Capture Pulse Rate Modal
File: /frontend/src/components/installer/CapturePulseRateModal.jsx
Modal Flow
┌─────────────────────────────────────────────────────────────────────────────┐
│ Capture Pulse Rate [X] │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Device: ABC12 - RAK2171 │
│ Pulse Channel: P1 (Water Meter) │
│ │
│ ───────────────────────────────────────────────────────────────────────── │
│ │
│ Initial Reading (recorded during installation): │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ 12,500.5 gallons • Dec 29, 2025 at 10:00 AM │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ Pulses received since initial reading: │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ 1,498 pulses │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ ───────────────────────────────────────────────────────────────────────── │
│ │
│ Enter Current Meter Reading: │
│ ┌────────────────────────────────┐ │
│ │ 12650.3 │ gallons │
│ └────────────────────────────────┘ │
│ │
│ ───────────────────────────────────────────────────────────────────────── │
│ │
│ Calculated Pulse Rate: │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ (12,650.3 - 12,500.5) / 1,498 = 0.1 gallons per pulse │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ │
│ [Cancel] [Save Pulse Rate] │
│ │
└─────────────────────────────────────────────────────────────────────────────┘Component Props
<CapturePulseRateModal
isOpen={showCaptureModal}
onClose={() => setShowCaptureModal(false)}
device={{
devEui: "ABC12",
fullDevEui: "0004A30B00ABC12",
model: "RAK2171"
}}
pulseChannel={{
channel: 1,
deviceType: "PWM",
initialReading: 12500.5,
initialTimestamp: "2025-12-29T10:00:00Z",
unit: "gallons",
pulseCount: 1498
}}
onSuccess={handlePulseRateCaptured}
/>2.5 New Service: Pulse Rate Service
File: /frontend/src/services/pulseRateService.js
// API calls to the new Calculate Pulse Rate Lambda
export async function getUncapturedPulseDevices(siteId) {
// GET /installer/pulse-rate/uncaptured/{siteId}
}
export async function getPulseCountsForDevice(devEui) {
// GET /installer/pulse-rate/{devEui}
}
export async function calculatePulseRate(devEui, channel, finalReading) {
// POST /installer/pulse-rate/calculate
}Part 3: Data Model Changes
3.1 InstallerAppDeviceDetails Table
New/Modified Fields:
Field | Type | Description |
|---|---|---|
| Number | Initial meter reading for P1 |
| Number | Initial meter reading for P2 |
| Number | Initial meter reading for P3 |
| String (ISO) | When P1 reading was taken |
| String (ISO) | When P2 reading was taken |
| String (ISO) | When P3 reading was taken |
| Boolean | Whether P1 pulse rate has been calculated |
| Boolean | Whether P2 pulse rate has been calculated |
| Boolean | Whether P3 pulse rate has been calculated |
3.2 LorawanDevicesTable (for _P1, _P2, _P3 devices)
Fields set by Calculate Pulse Rate Lambda:
Field | Type | Description |
|---|---|---|
| Number | Calculated pulse rate (e.g., 0.1) |
| String | Unit per pulse (e.g., "gal") |
| String (ISO) | When pulse rate was calculated |
| Map | Metadata about the calculation |
Part 4: Implementation Order
Phase 1: Backend (Priority: High)
[ ] Create
calculate_pulse_rate_lambda.py[ ] Update CDK stack with new Lambda and IAM permissions
[ ] Add API Gateway routes for pulse rate endpoints
[ ] Update migration lambda schedule to 15 minutes
[ ] Deploy and test backend
Phase 2: Frontend - Form Changes (Priority: High)
[ ] Update
InstallerDeviceFormPage.jsxpulse counter inputs[ ] Update
installationService.jssubmission payload[ ] Update
installation_submission_lambda.pyto handle new fields
Phase 3: Frontend - Review Page (Priority: High)
[ ] Create
UncapturedPulseCountsSection.jsxcomponent[ ] Create
CapturePulseRateModal.jsxcomponent[ ] Create
pulseRateService.jsservice[ ] Integrate into
InstallerReviewPage.jsx
Phase 4: Testing & Refinement (Priority: Medium)
[ ] End-to-end testing with real devices
[ ] Edge case handling (no pulse data, timeout, etc.)
[ ] UI/UX refinements based on testing
Part 5: Edge Cases & Error Handling
Scenario 1: No Pulse Data Available Yet
Cause: Migration hasn't run yet, or device hasn't sent uplinks
UI: Show "Waiting for pulse data..." with disabled button
Auto-refresh: Poll every 30 seconds for pulse data availability
Scenario 2: Zero Pulses Recorded
Cause: Device malfunction, or meter hasn't moved
UI: Show warning "No pulses recorded - check device connection"
Action: Allow manual pulse rate entry as fallback
Scenario 3: Pulse Rate Seems Unreasonable
Cause: Incorrect meter reading, or pulse count issue
UI: Show warning if calculated rate is outside expected range
Action: Allow user to confirm or re-enter readings
Scenario 4: Legacy Devices
Cause: Devices installed before this feature
UI: Show "Manual Entry" option
Action: Allow direct pulse rate input for legacy devices
Part 6: Success Criteria
✅ Installer can submit device with initial meter reading (no pulse rate)
✅ Migration runs every 15 minutes, creating _P1/_P2/_P3 devices
✅ Review page shows devices needing pulse rate capture
✅ Installer can capture final meter reading
✅ System calculates pulse rate:
(final - initial) / pulseCount✅ Calculated pulse rate is saved to LorawanDevicesTable
✅ Device shows as "complete" after pulse rate capture
Appendix: Calculation Example
Initial Reading: 12,500.5 gallons @ 10:00 AM
Final Reading: 12,650.3 gallons @ 2:30 PM
Pulses in Timestream: 1,498 pulses
Meter Delta = 12,650.3 - 12,500.5 = 149.8 gallons
Pulse Rate = 149.8 / 1,498 = 0.1 gallons per pulse
Result: Every pulse from this device represents 0.1 gallons of water