Thursday, April 18, 2024

Timestream live dashboards in Grafana

 Amazon Timestream is a strange beast.  It is a time series "database" but under the hood it behaves like a bunch of flat files that are partitioned by time and something else.  That something could be the measurement name (this is the default) but it could also be dimension that you specify.  You better specify them carefully, though, because once the table is created you can't ever change them.

Partitioning is automatic.  If you insert data as time, measurement_name, value, then at the beginning all your rows will go into a few (maybe one) partitions.  As your data grows, partitions get larger and eventually new partitions are created.  Distinct measurements can be within a single partition or spread across many partitions.  There is no way to really tell how this works - under the hood, partitions may be split (but they probably aren't) or new partitions might be appended and indexed.  It keeps track of what measurements and time ranges are within each partition using an index.  This mean that certain queries, like asking for data for a specific measurement and time range, are faster - it can figure out what blocks to look in using the index.  But you pay for each gigabyte scanned.  If you ask for every measurement for a short time range, you can't really predict how many partitions will be scanned.

Amazon tells you to trust them - they will make new partitions when needed in order to minimize query costs.  In Grafana, though, you still have to be very careful.  In the case where you have only a few measurements, then a given time range (say the last 15 minutes) might be in a single partition, with lots of measure_names in it.  If you query them one by one, then you might be paying to scan the same data multiple times, and paying $0.01/GB scanned every time.

In my case, measure_name has pretty low cardinality - there are maybe 50 distinct measurements.  What that means is that my partitions are likely by time only.  For queries that get the most recent data - for example the one below - that isn't necessarily a good thing.


SELECT DISTINCT
       measure_name,
       COALESCE(
           FIRST_VALUE(measure_value::double) OVER (
               PARTITION BY measure_name
               ORDER BY time DESC
               ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
           ),
           CASE WHEN FIRST_VALUE(measure_value::boolean) OVER (
               PARTITION BY measure_name
               ORDER BY time DESC
               ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
           ) THEN 1 ELSE 0 END
       ) AS "value",
       FIRST_VALUE(time) OVER (
           PARTITION BY measure_name
           ORDER BY time DESC
           ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
       ) AS "last update"
FROM $__database.$__table
WHERE time >= ago(5m)
ORDER BY measure_name


I thought I could put this query into a table, hide the table, then have 20 individual gauges refer to its data using the table visualization as the datasource for the remaining dials and gauges.  Turns out that's not how Grafana works.  Instead of fetching the data once, it runs this query once per gauge.  The result is that my development (of the dashboards) is costing me $0.20/hr in timestream queries.  Not a huge deal, but still kind of annoying.

What Grafana really needs is the concept of a 'dashboard query' that can be a datasource for multiple visualizations on the dashboard.  I'll be putting this in as a feature request soon.

The point is this: Timestream works very well with Grafana, but construct your dashboards and queries very, very carefully.


Sunday, March 3, 2024

Hot Tub Monitor, Part 1

 Today I am working on a better hot tub monitor.  My hot tub has three separate pumps. I want to be able to monitor its energy consumption in greater detail, and to have it send me alerts in the event that the heater fails.

To make it fun (and cheap) I decided to use the Shelly I4 Plus module coupled with the I/O Addon. To this I added three DS18B20 temperature sensors and a couple of AC current switches (two shown below, two more to be added later).  The current switches are self-powered and act like a relay contact that closes when the current to each motor is above a few hundred milliamps.  I already have a current monitor, so I don't need to know the actual current - all I'm looking for here is state monitoring.



You can run Tasmota on these Shelly devices, but for this I don't need it.  I do, however, want to be able to control the update rate.  Initially I am just going to report state changes on a fixed interval, but the intent is to implement reporting on state change with a minimum reporting rate if temperature or state has not changed.  This will allow me to get an immediate report when the motors start, but not overwhelm my time-series database with a lot of duplicate data.  To accomplish this I will use the built-in scripting that the Shelly Gen 3 modules support.  I will improve this script later to implement the report-by-exception but to keep it simple I want to show an example of how the scripts work.


function updateTemps() {
  Shelly.call("Temperature.Getstatus", { id: 100 },
    function (response) {
       MQTT.publish("hottub/t1", JSON.stringify(response.tF), 0, false);
    }
  )
   
   Shelly.call("Temperature.Getstatus", { id: 101 },
    function (response) {
       MQTT.publish("hottub/t2", JSON.stringify(response.tF), 0, false);
    }
  )
   
   Shelly.call("Temperature.Getstatus", { id: 102 },
    function (response) {
       MQTT.publish("hottub/t3", JSON.stringify(response.tF), 0, false);
    }
  )
}


let tempTimer = null;

function start() {
    print("starting");
    tempTimer = Timer.set(1000, true, updateTemps, null);
}
 
function stop() {
  print("stopping");
  Timer.clear(tempTimer)
}

The device is already configured to connect to my MQTT broker.  Updates are done by making an RPC call to the Temperature.GetStatus function in the Shelly.  Each sensor has a unique ID; the three external sensors are Inumbers 100, 101, and 102.  You can get a sensor list by calling SensorAddon.GetPeripherals which returns a little bit of JSON:

{
    "digital_in": {},
    "ds18b20": {
        "temperature:100": {
            "addr": "40:118:243:67:212:9:96:228"
        },
        "temperature:101": {
            "addr": "40:204:240:67:212:47:73:182"
        },
        "temperature:102": {
            "addr": "40:242:0:67:212:231:49:22"
        }
    },
    "dht22": {},
    "analog_in": {},
    "voltmeter": {}
}

All of these RPC calls are accessible through the either your browser or curl, so it's easy to figure out the returned message structure (but the documentation is also very clear).

Happy hacking!


Wednesday, February 7, 2024

IPv6 Only Ubuntu instance on Amazon Web Services

Pay Per IPv4 Address?


You're probably aware of the AWS plan charge for IPv4 Addresses.  The costs for an address is$0.005 per hour, which amounts to about $3.65/month.  That's not going to break the bank, but if you use a few tiny instances (t3.micro or even t3.nano) you might pay more for the IP address than for the virtual machine it's attached to.  


Secondarily, I wondered if I can create an EC2 instance that has only IPv6, and would there be problems?  I decided to try.

Configuring IPv6 on EC2

Setting up IPv6 on EC2 takes a little work, most of it being done in the VPC.  Amazon has documentation that explains it.  The gist is:

  1. Associate a public IPv6 border address block to your VPC.  To do this, open the VPC console, select the VPC your instances are on, and edit the CIDR blocks.  Add an "Amazon-provided IPv6 CIDR block" associated with your ec2 region or AZ
  2. Create at least one subnet within that block. Typically this will be a subset of the block (for example a /64).  Your instances will choose an address from within this subnet.
  3. Make sure your EC2 instance belongs to this VPC/subnet (if it doesn't already) and auto-assign it an address
  4. Update the routing tables to include an IPv6 default rout

My instructions here are very incomplete, but the Amazon instructions are good (though long), so follow those.

APT Updates


The first pain point I ran into was breaking apt.  There are two reasons for this.  One is that you have to specifically configure apt to use IPv6.  You can do this by adding a file:

root# cat /etc/apt/apt.conf.d/1000-force-ipv6-transport
Acquire::ForceIPv6 "true";

Second, if you built your Ubuntu instance from one of the EC2 templates, it's going to have apt repositories listed that don't support IPv6.  In my case this was us-east-1.ec2.ports.ubuntu.com.  It surprises me that there would be such a repository, obviously within AWS, but without an IPv6 address.

To fix this, I changed /etc/apt/sources.list to point toward a more generic Ubuntu package source:

deb http://ports.ubuntu.com/ubuntu-ports/ jammy main restricted
deb http://ports.ubuntu.com/ubuntu-ports/ jammy-updates main restricted
deb http://ports.ubuntu.com/ubuntu-ports/ jammy universe
deb http://ports.ubuntu.com/ubuntu-ports/ jammy-updates universe

This probably has some speed implications, and most likely some cost implications as well as the package repository is no longer within the AWS region as your instance, but that is likely to cost you pennies at most and the speed isn't that important for regular patching and updates.

Is it connectable?


All the testing I have done so far has been fine.  I work at a place that has good IPv6 infrastructure, as does my cable internet service (Spectrum).  Mobile devices seem to be ahead of the curve with respect to IPv6.  So, for my use case, IPv6 only seems fine.  I do have concerns, though, about connectivity - how many IPv4-only clients are out there?  No idea.  I don't think I would risk it for a truly "production" application until I understand the answer to that question.

Docker


Docker containers running within the EC2 instance are a little more problematic.  If those containers need to reach out to the internet for any reason, they won't work unless you specifically enable IPv6 (or set up some kind of proxy) for them.  That's an issue I was abled to solve, and I will explain how another day.




Friday, December 17, 2021

Relative solar panel power output in Flux

The problem: I want to display solar generation data as a percentage of the max output each individual inverter has generated over its lifetime.  That will let me more easily compare panels of widely varying power outputs, as we have some huge arrays (2 megawatts) and some tiny ones (1000-2000 watts).

In an SQL like query language what I'm looking for is something like this:

SELECT uuid, _time, W / MAX(W) ...

but how do you do that with flux?  The challenge gets back to being able to think about queries in terms of streams that you later join together.  I solved the problem this way:

//
// For each inverter, find the maximum power this year
// The result is a series of maximums (one per tag set, which
// in this case resolves to individual inverters)
//
wmax = from(bucket: "vpp")
  |> range(start2021-01-01T00:00:00Z, stop: v.timeRangeStop)
  |> filter(fn: (r) => r["_measurement"] == "data" and r["_field"] == "W")
  |> max()

//
// Now pull out just the power output - this is the time series data
//
wdata = from(bucket: "vpp")
  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)
  |> filter(fn: (r) => r["_measurement"] == "data" and r["_field"] == "W")
  |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)

//
 // Finally, Join these together on uuid, the result will contains columns
// including _time_wdata, _value_wmax and _value_wdata. Follow the join
// with a map() that actually calculates the percentage (and as as side
// effect filters out extra tags/columns so that it displays nicely in
// grafana)
//
join(tables: {wdata: wdata, wmax: wmax}, on: ["uuid"] )
|> map( fn: (r) => ({
uuid: r.uuid,
_time: r._time_wdata,
_value: r._value_wdata/r._value_wmax
})
)
|> yield()

The result gives you a set of relative power curves normalized to each system's max this year



For performance reasons, it's best to avoid any kind of map() in the wmax or wdata queries.  The goal for things like this is to use the pushdown pattern, meaning use functions that let the query be performed (and the result set reduced in size) at the storage layer, rather than in memory.  This blog post does a good job of explaining this, and the pushdown patterns are explained Here.  Note that map() can't be pushed down, so in general I think the best strategy is to avoid map() until the very end - after the results have been filtered and stats like min(), max(), mean() have already run on the storage side.

Flux is a different way of thinking.  If you have spent decades thinking in SQL (like me), it takes a little time to get used to this.  Flux tends to be wordier, but what you get out of it is something that's much more flexible, so I think it's worth it.

I hope these flux notes help others.  I've grown to like flux a lot, but I found the learning curve to be a little steep, and whenever I'm faced with a new type of problem I have to really think about the data model and what is coming out of each step in the stream I set up.  I tend to bug the InfluxData staff, but fortunately they have been very patient and helpful.








Wednesday, December 8, 2021

Closing the Initial Gap on Grafana State Timelines (Flux queries)

I have an application that feeds periodic state into an influxdb 2 table.  This data can be queried rather simply:

from(bucket: "vpp")
  |> range(start: vTimeRangeStart, stop: v.timeRangeStart)
  |> filter(fn: (r) => r.uuid == "${uuid}" and r._field == "State")|
  |> last()
  |> keep(columns: ["_time", "_value"])


This sort of works, but it causes an unsightly gap in the data.  A state exists there, but it is before vTimeRangeStart so the query doesn't see it.



My flux solution to this problem is to fetch an initial value up to 24 hours prior to vStartTime.  That proved to be a little tricky but Flux does provide an experimental way to manipulate times:

import "experimental"

initialStateTimeRangeStart = experimental.subDuration(
    d:24h,
    from: v.timeRangeStart
)

Using this you can then get an initial state:

initial=from(bucket: "vpp")
  |> range(start: initialStateTimeRangeStart, stop: v.timeRangeStart)
  |> filter(fn: (r) => r.uuid == "${uuid}" and r._field == "State")
  |> last()
  |> keep(columns: ["_time", "_value"])


Here is the complete query fetches the initial state and appends it to the recent data:

import "experimental"

initialStateTimeRangeStart = experimental.subDuration(
  d: 24h,
  from: v.timeRangeStart
)

initial=from(bucket: "vpp")
  |> range(start: initialStateTimeRangeStart, stop: v.timeRangeStart)
  |> filter(fn: (r) => r.uuid == "${uuid}" and r._field == "State")
  |> last()
  |> keep(columns: ["_time", "_value"])

recent=from(bucket: "vpp")
  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)
  |> filter(fn: (r) => r.uuid == "${uuid}" and r._field == "State")
  |> keep(columns: ["_time", "_value"])

union(tables: [initial, recent])
  |> sort(columns: ["_time"])
  |> yield()


with a pleasant result that extends the previous state on to the graph:



A few things to watch out for:

  • It appears to me that v.timeRangeStart is a 'time' in grafana and a 'duration' in chronograf (or influx 2). For most queries you won't notice this, as range() accepts either, but in this case, in chronograf you have to convert the time to a duration first, so the time calculation is a bit different (see below)
  • The documentation for union warns you that the sort order is not necessarily maintained, and they are right.  That sort() at the end is required.
  • Multiple yields lets you see what's happening along the way, so feel free to inital |> yield(name: "initial") and recent |> yield(name: "recent") if it helps with debugging.

As I mentioned, in the chronograf or influx data explorer you need to modify the time offset calculation:

initialTimeRangeStart = experimental.subDuration(
   d: 24h,
   from: experimental.addDuration(
      d: duration(v: v.timeRangeStart),
      to: now()
   )
)

I don't find this ideal but it's still useful to know how to do it, as developing queries in influxdb data explorer is something I find easier as it seems to give more meaningful error messages when things go haywire, and it's a little better at presenting the raw query results.

Note: I'm not sure that anybody reads any of this, I know that the search indexes all cover it, so perhaps somebody will find this useful some day.  If you're that person, feel free to comment here so I know someone is listening, thanks in advance!

Thursday, January 30, 2020

Influx 2.0 queries with Grafana


Influx 2.0 (flux) queries in grafana are a little on the messy side.  Here are a comple of examples that might be useful.


Here is an example of summing up a number of fields for display on a "single stat" panel widget

from(bucket: "powermon")
|> range(start: -1h)
|> filter(fn: (r) => r._measurement == "modbus")
|> filter(fn: (r) => r._field == "pv1" or r._field == "pv2" or r._field == "pv3" or r._field == "pv4" or r._field == "pv5" or r._field == "pv6" or r._field == "pv7")
|> last()
|>keep(columns:["_value"])
|> sum()


When playing with graphs, things are more or less as you'd expect:

from(bucket: "powermon")
|> range(${range})
|> filter(fn: (r) => r._measurement == "modbus")
|> filter(fn: (r) => r._field == "pv1" or r._field == "pv2" or r._field == "pv3" or r._field == "pv4" or r._field == "pv5" or r._field == "pv6" or r._field == "pv7")
|> aggregateWindow(every: 1m, fn: (tables=<-, column) => {
return tables |> mean(column: column) |> map(fn: (r) => ({r with _value: r._value*1000.0}))
})
|> yield(name: "mean")