Skip to main content
8 of 15
added 9 characters in body

There's lots of misunderstandings here, but here's the gist of it.

What you want to do is a perfect application of an Arduino. No need to buy a coulomb counter, instead, you should make one with your Arduino.

I would then add this amp/second value to a variable.

First correction, it's an amp*second, NOT an amp/second. You are multiplying amps by the number of seconds that current value existed.

amp*seconds / 3600 = amp*hours. Read about that here: https://en.wikipedia.org/wiki/Ampere_hour. Multiply that by the voltage over that same time period and you've got amps*hours*volts = (amps*volts)*hours = Watts*hours = units of energy! Remember, your house electricity it purchased in units of kilowatt hours, which is kilowatts*hours, or kWhr. And in engineering units a joule is the standard unit of energy, and it is simply Watts*seconds. So, you can convert kWhr to joules like this, where units are in square brackets [].

Joules = kWhr * 1000 [Whr/kWhr] * 3600 [sec/hr] = [Watts*sec] = [Joules]

So, 1 kWhr x 1000 x 3600 = 3600000 Joules = 3600kJ

I would also increment another variable for each second.

That doesn't make any sense. Don't do that. You've already captured the seconds by adding your new bit of amps*seconds to your old sum of it.

DO account for voltage too! What you're really after is energy, which can be expressed in units of joules or kWhr, as already described above.

Is it too much a stretch to poll the amperage every second and call it amp*second?

(my corrections in bold)

No, absolutely not! That's exactly what an amp*second is! It's the current, amps, times the number of seconds that current existed. That's a coulomb counter!

How can I make this more accurate?

This is the part where the engineer and programmer (you) determines the quality of the result.

  1. Don't assume 1 second elapsed because you told it to. Measure it for each and every measurement!
  2. The smaller the time interval, the more accurate, since it allows you to capture the small current fluctuations, so use a smaller time slice than 1 second. I'd start with 10ms.
  3. Measure voltage AND current AND actual time elapsed on this loop as close to the same time as possible.
  4. Do NOT use blocking techniques, such as delay() or delayMicroseconds().
  5. What you're doing when you're multiplying a new amp reading x seconds, and adding it to the previous sum is called numerical integration. You're simply numerically integrating, or summing, the area under the Amp (y-axis)-time (x-axis) curve, or, if you're doing what I'm saying to do intead: the Watt (y-axis)-time (x-axis) curve, where the area under the curve is the total energy collected. Don't do rectangular integration, do trapezoidal integration following the trapezoidal rule! It's more accurate!
  6. Calibrate your clock! An Arduino's clock can be off by a couple percent. Figure out how much it's off by comparing its clock output over, say, 10 minutes, and comparing it to a better clock source, say...an internet time server displaying the atomic clock in Colorado or wherever it is. Then, multiply each measurement by a scaling factor to fix it!
  7. On the same lines as above, ideally you'd also compensate in real-time for temperature, as the clock frequency drifts with temperature.
  8. Calibrate the voltage measurement.
  9. Calibrate the current measurement.

Extra: when is trapezoidal integration most beneficial?

The merits of trapezoidal integration can be argued, and it's probably not as important as I originally made it sound, but it is super easy to implement, so why not!? Also, it is most important when:

  1. The time sampling rate is super low.
  2. The rate of change of readings (ie: their derivative, or slope) is super high.
  3. The readings consistently have a rising slope (rectangular integration _under_estimates the area in this case) OR a consistently falling slope (rectangular integration _over_estimates in this case).
  4. When you care about real-time readings in this instant, not just overall trends or averages. This is because trapezoidal integration instantly removes overestimation and underestimation error which can accumulate over short periods but then "de-accumulates", or is negated, over long periods, when the opposite slope occurs in the data.
  5. When the readings consistently rise at a different rate than the fall, as this results in asymmetric accumulation of error. This means that the rectangular integration error accumulated by the values rising distance delta_y will NOT be fully negated by error in the opposite direction when the values fall distance delta_y. Therefore, even over long periods, rather than seeing the error negated, it will accumulate more and more over time. [Note: I'd need to analytically/numerically play with this hypothesis for a while to prove it conclusively to myself, but I'm pretty sure it is correct].

Full coulomb counter example in code:

Here's a full coulomb counter example in code. You just need to implement a few functions to read samples and do calibrations is all.

I'm borrowing the timestamp-based cooperative multitasking part of this code from my answer about cooperative multitasking here, so go check it out for more information on that.

As written, this code compiles, but I haven't wired anything up or tested it. Here's the compilation output:

Sketch uses 3750 bytes (12%) of program storage space. Maximum is 30720 bytes.
Global variables use 262 bytes (12%) of dynamic memory, leaving 1786 bytes for local variables. Maximum is 2048 bytes.

coulomb_counter.ino:

Jump to the bottom for the setup() and loop() functions. They are both really short.

/// Coulomb counter example
/// By Gabriel Staples
/// See: https://arduino.stackexchange.com/questions/75932/calculating-amp-hrs-of-a-solar-panel/75937#75937

/// @brief      A function-like macro to get a certain set of events to run at a desired, fixed 
///             interval period or frequency.
/// @details    This is a timestamp-based time polling technique frequently used in bare-metal
///             programming as a basic means of achieving cooperative multi-tasking. Note 
///             that getting the timing details right is difficult, hence one reason this macro 
///             is so useful. The other reason is that this maro significantly reduces the number of
///             lines of code you need to write to introduce a new timestamp-based cooperative
///             task. The technique used herein achieves a perfect desired period (or freq) 
///             on average, as it centers the jitter inherent in any polling technique around 
///             the desired time delta set-point, rather than always lagging as many other 
///             approaches do.
///             
///             USAGE EX:
///             ```
///             // Create a task timer to run at 500 Hz (every 2000 us, or 2 ms; 1/0.002 sec = 500 Hz)
///             const uint32_t PERIOD_US = 2000; // 2000 us pd --> 500 Hz freq
///             bool time_to_run;
///             uint32_t actual_period_us;
///             CREATE_TASK_TIMER(PERIOD_US, time_to_run, actual_period_us);
///             if (time_to_run)
///             {
///                 run_task_2();
///
///                 // OR, if `run_task_2()` needs the actual period that just occurred, for 
///                 // whatever reason, you may pass it in to your `run_task_2()` function:
///                 run_task_2(actual_period_us);
///
///                 // OR, just do all your code right here instead of in `run_task_2()`
///             }
///             ```
///
///             Source: Gabriel Staples 
///             https://stackoverflow.com/questions/50028821/best-way-to-read-from-a-sensors-that-doesnt-have-interrupt-pin-and-require-some/50032992#50032992
/// @param[in]  period_desired_us  (uint32_t) The desired delta time period, in microseconds; 
///                             note: pd = 1/freq; the type must be `uint32_t`.
/// @param[out] time_to_run     (bool) A `bool` whose scope will enter *into* the brace-based scope block
///                             below; used as an *output* flag to the caller: this variable will 
///                             be set to true if it is time to run your code, according to the 
///                             timestamps, and will be set to false otherwise.
/// @param[out] actual_period_us  (uint32_t) The actual period, in us, since the last time it was 
///                             time to run--ie: since the last time `time_to_run` was set to true.
/// @return     NA--this is not a true function
#define CREATE_TASK_TIMER(period_desired_us, time_to_run, actual_period_us)                                            \
{ /* Use scoping braces to allow multiple calls of this macro all in one outer scope while */                          \
  /* allowing each variable created below to be treated as unique to its own scope */                                  \
    time_to_run = false;                                                                                               \
                                                                                                                       \
    /* set the desired run pd / freq */                                                                                \
    const uint32_t PERIOD_DESIRED_US = period_desired_us;                                                              \
    static uint32_t t_start_us = micros();                                                                             \
    uint32_t t_now_us = micros();                                                                                      \
    uint32_t period_us = t_now_us - t_start_us;                                                                        \
    actual_period_us = period_us;                                                                                      \
                                                                                                                       \
    /* See if it's time to run this Task */                                                                            \
    if (period_us >= PERIOD_DESIRED_US)                                                                                \
    {                                                                                                                  \
        /* 1. Add PERIOD_DESIRED_US to t_start_us rather than setting t_start_us to t_now_us (which many */            \
        /* people do) in order to ***avoid introducing artificial jitter into the timing!*** */                        \
        t_start_us += PERIOD_DESIRED_US;                                                                               \
        /* 2. Handle edge case where it's already time to run again because just completing one of the main */         \
        /* "scheduler" loops in the main() function takes longer than PERIOD_DESIRED_US; in other words, here */       \
        /* we are seeing that t_start_us is lagging too far behind (more than one PERIOD_DESIRED_US time width */      \
        /* from t_now_us), so we are "fast-forwarding" t_start_us up to the point where it is exactly */               \
        /* 1 PERIOD_DESIRED_US time width back now, thereby causing this task to instantly run again the */            \
        /* next time it is called (trying as hard as we can to run at the specified frequency) while */                \
        /* at the same time protecting t_start_us from lagging farther and farther behind, as that would */            \
        /* eventually cause buggy and incorrect behavior when the (unsigned) timestamps start to roll over */          \
        /* back to zero. */                                                                                            \
        period_us = t_now_us - t_start_us; /* calculate new time delta with newly-updated t_start_us */                \
        if (period_us >= PERIOD_DESIRED_US)                                                                            \
        {                                                                                                              \
            t_start_us = t_now_us - PERIOD_DESIRED_US;                                                                 \
        }                                                                                                              \
                                                                                                                       \
        time_to_run = true;                                                                                            \
    }                                                                                                                  \
}

// Convert microseconds to seconds
#define US_TO_SEC(us) ((us)/1000000UL)
// Convert Joules to KWh (Kilowatt*hours) 
#define JOULES_TO_KWHRS(joules) ((joules)/3600/1000)

/// @brief      Obtain a corrected time measurement from a raw time measurement.
/// @details    Assuming you have done experiments to determine your microcontroller (mcu) 
///             clock's error, you can correct for it with this function. Pass in a time 
///             measurement the mcu has timed directly, and get back a corrected value.
/// @param[in]  raw_sec     A raw time measurement, in seconds
/// @return     A corrected time measurement, in seconds
float do_time_correction(float raw_sec)
{
    // You determine the correct calibration constant. Perhaps it is 0.9813, or perhaps it is
    // 1.19.... You will need to determine this for each individual mcu.
    constexpr float TIME_CORRECTION_CONST = 1.0; // default: 1.0
    float corrected_sec = raw_sec*TIME_CORRECTION_CONST;
    return corrected_sec;
}

/// @brief      Obtain a new instantaneous (at this moment) current reading, in Amps
/// @param      None
/// @return     Current, in Amps
float get_current()
{
    // you implement this
}

/// @brief      Obtain a new instantaneous (at this moment) voltage reading, in Volts
/// @param      None
/// @return     Voltage, in Volts
float get_voltage()
{
    // you implement this
}

/// @brief      Run the coulomb counter task to sum the total energy received from the solar panels
/// @param[out] total_energy_joules_p  (optional) A pointer to a float to receive back the total 
///                         energy value acquired, in Joules (Watts*sec). Pass in `nullptr` to not
///                         receive back this value.
/// @param[in]  reset_sum   (optional) pass in true to reset the internally-stored "total energy 
///                         acquired" value during this call, prior to performing the latest 
///              power_watts_avg           calculation, if applicable.
/// @return     true if a new total_energy_joules value was just calculated and passed back, or false if no
///             value was passed out since it wasn't time to obtain and calculate a new value
bool run_coulomb_counter(float* total_energy_joules_p = nullptr, bool reset_sum = false)
{
    // Set this task to run at 100 Hz
    constexpr uint32_t PERIOD_DESIRED_US = 10000; // 10000us = 10ms, or 100Hz run freq
    
    bool time_to_run = false;
    uint32_t actual_period_us;
    CREATE_TASK_TIMER(PERIOD_DESIRED_US, time_to_run, actual_period_us);
    if (time_to_run)
    {
        // Time to obtain new samples and perform some numerical integration to obtain the total
        // energy received. 

        static float total_energy_joules = 0;
        // the previous power calculation, in watts; required for trapezoidal integration
        static float power_watts_old = 0; 

        if (reset_sum)
        {
            total_energy_joules = 0;
        }

        // take new samples, & calculate power from those samples
        float current_amps = get_current(); 
        float voltage = get_voltage(); 
        float power_watts = current_amps*voltage;

        float actual_period_sec = US_TO_SEC((float)actual_period_us);
        actual_period_sec = do_time_correction(actual_period_sec);

        // Perform trapezoidal integration to obtain the "area under the curve", which is equal to
        // the energy in joules. Imagine a plot where the y-axis is power and the x-axis is time. If
        // you split the x-axis up into segments of width PERIOD_DESIRED_US, then the trapezoidal
        // area under the curve, within each of those time segments, is equal to time_delta x
        // (power_old + power)/2. This is the power obtained during that time period.
        float power_watts_avg = (power_watts + power_watts_old)/2;
        power_watts_old = power_watts; // prepare for next iteration
        float energy_joules = actual_period_sec*power_watts_avg; 

        total_energy_joules += energy_joules;

        if (total_energy_joules_p != nullptr)
        {
            // output this value back to the user
            *total_energy_joules_p = total_energy_joules;
        }
    }

    return time_to_run;
}

void setup()
{
    // do whatever you need to here
    Serial.begin(115200);
}

void loop()
{
    // Energy, in Joules, or Watt*seconds. To convert to Watt*hours, simply divide by 3600 [sec/hr]
    float total_energy_joules;
    bool new_measurements_made = run_coulomb_counter(&total_energy_joules);
    
    // Let's do an event-based print where we print the latest total_energy_joules value at a rate
    // of 2 Hz, which is typical for digital displays showing information expected to be read by
    // us slow humans. Since the run_coulomb_counter() task is running at 100 Hz, that means we
    // need to print the latest value every 50 runs.
    if (new_measurements_made)
    {
        static uint16_t run_count = 0;
        run_count++;
        if (run_count % 50 == 0)
        {
            float total_energy_kwh = JOULES_TO_KWHRS(total_energy_joules);
            Serial.print("total energy received (J) = ");
            Serial.print(total_energy_joules);
            Serial.print("; (kWh) = ");
            Serial.println(total_energy_kwh);
        }
    }
}

References:

  1. [my own answer about generic, timestamp-based cooperative multitasking] https://stackoverflow.com/questions/50028821/best-way-to-read-from-a-sensors-that-doesnt-have-interrupt-pin-and-require-some/50032992#50032992