2

I am interested in calculating the amp/hour output of a solar panel using an Arduino. I am confident I can get the voltage (with a voltage divider) & the amperage (with an ACS712). My question is about the math of calculating Amp*hour.

Here is my plan:

  1. My plan is to pull the voltage and amperage every second.
  2. I would then add this amp*second value to a variable. I would also increment another variable for each second.
  3. I would then be able to get an average or total of Ampseconds from these 2 variables. I could also divide the ampsecond by 3600 if I wanted Amp*Hour.

Is it too much a stretch to poll the amperage every second and call it amp/second? How can I make this more accurate?

4
  • and why do you measure the voltage?
    – Juraj
    Commented May 27, 2020 at 19:16
  • 3
    Just one minor comment: It's Amp hours (Ah) and Amp seconds, not Amp/Hours. Otherwise your method for calculation seems correct. I would not expect very accurate results though. You could use the voltage in the same way to calculate Watt Hours.
    – StarCat
    Commented May 27, 2020 at 19:41
  • I was in the process of editing your answer, then decided against it. I'll just say it here instead: there's no such thing as amp/hour as a useful measurement. What you mean is amps*hours, or Ah (en.wikipedia.org/wiki/Ampere_hour). Also, change the word "pull" to poll. Those are two completely different English words, and what you mean is poll, or "ask repeatedly", not "pull", or "exert a force towards something", or "draw or haul towards you". Commented May 27, 2020 at 19:45
  • @Juraj I mentioned I have the voltage if by chance it was needed.
    – RonSper
    Commented May 27, 2020 at 22:36

3 Answers 3

6

How to make your own coulomb counter (Amp*hours [Ahr] meter, or Watts*hours [Whr] energy meter) with an Arduino

Quick summary:

Jump straight down to the "Full coulomb counter example in code" section if you just want the final answer.

Details

There are a lot of misunderstandings here, but here is 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!

A. Making the coulomb counter as good as possible

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.

B. Extra: when is trapezoidal integration most beneficial?

You might call the following list my "5 hypotheses about the benefits of trapezoidal integration".

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. [This may apply only when there is jitter in the sample rate--see @Edgar Bonet's comment here] When the readings consistently rise at a different rate than they 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:

This code is now in my eRCaGuy_hello_world repo here: coulomb_counter_with_cooperative_multitasking_macro.ino.

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_with_cooperative_multitasking_macro.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. Note that the previously-cached value is 
///                         passed out in the event it's not time to calculate a new value.
///                         The return value of this function will indicate whether a new value
///                         or cached value is passed out here.
/// @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 it is time for that).
/// @return     true if a new total_energy_joules value was just calculated and passed back via the
///             pointer above, or false if an old, cached value was passed out instead 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

    static float total_energy_joules = 0;

    if (reset_sum)
    {
        total_energy_joules = 0;
    }
    
    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. 

        // the previous power calculation, in watts; required for trapezoidal integration
        static float power_watts_old = 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 energy (Joules) 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: Stack Overflow: How to do high-resolution, timestamp-based, non-blocking, single-threaded cooperative multi-tasking

See also:

  1. [my answer] Numerical derivation and integration in code for physics, robotics, gaming, and controls
11
  • So how will the OP measure current? A high current, low value resistor in-line with the current flow, and using the Arduino ot measure the voltage crop over the resistor?
    – Duncan C
    Commented May 27, 2020 at 20:36
  • That would work, but he mentioned he had this instead: ACS712. Commented May 27, 2020 at 20:43
  • I'll post sample code for the whole coulomb counter thing later too. Commented May 27, 2020 at 20:43
  • 1
    With a constant sampling rate, your hypothesis 5 is wrong, as rectangular and trapezoidal only differ by the weights of the first and last samples. However, if the sampling rate is unsteady, and correlated with the signal derivative (e.g. faster sampling when the signal rises), then rectangular integration would indeed accumulate errors. Lacking this correlation, the error introduced by the sampling jitter into the rectangular integrator would “wander” (i.e. random walk) as √t. Trapezoidal definitely copes better with very high-jitter sampling. Commented May 28, 2020 at 10:49
  • 1
    I just UP-voted this answer. No idea why somebody would down-vote it. Very thorough and easy to understand. I’d have to study it a lot harder than I have time now to understand your #5 about trapezoidal integration vs. rectangular. As you say, though, it’s easy, so why not?
    – Duncan C
    Commented May 8, 2022 at 21:23
2

You want a device called a "coulomb counter". Such a device measures the total current that flows through it.

I've seen some for Arduino. I believe SparkFun sells one. From a quick search it looks like theirs might be too low voltage for a large solar panel though.

4
  • Would I be able to pull the amperage faster to get an equivalent output or is this a fool's errand? Thanks for the tip of coulomb counter. LTC4150 seems cheap and simple to use.
    – RonSper
    Commented May 27, 2020 at 18:05
  • A coulomb counter measures amp/hours of current directly. What is the max voltage of your solar panel? Assuming it can handle the voltage & amount of current you need to measure, it should be perfect for your needs.
    – Duncan C
    Commented May 27, 2020 at 18:48
  • I don't like this answer, because it ignores the fact that an Arduino IS a coulomb counter if used correctly, but I won't downvote it because this answer isn't incorrect per se. @RonSper, an Arduino can act as a coulomb counter just fine. It is not a fool's errand, it is exactly what I'd do. Commented May 27, 2020 at 19:38
  • In case you're interested, as I think it's a pretty cool use-case for Arduino, I just did a full implementation of the coulomb counter algorithm from scratch, in code, in my answer. Commented May 28, 2020 at 1:48
1

There are a number of good choices for a sampling device. Some like the INA2221 can do simultaneous I & V at a higher sample rate sampling and accumulate or average the results to produce a low rate data stream better suited to a processor with limited resources without sacrificing anything.

If the idea is to test the output of a solar panel the terminal voltage isn't entirely irrelevant. Panels aren't always maximally illuminated.

If this concept is tied to battery charging I think there's a dimension missing from the concept guiding the approach - the charge acceptance of a storage cell is not a constant. The implicit use for this is to monitor the battery state of charge in order to either disconnect the charger entirely or change to charge balancing and or float modes.

Simply relying on Amp-hours_out = constant * Amp-Hours_in would need to set the constant at the maximum value to avoid trying to drive current at a high rate into fully charged cells. That charger relies on a long low rate charge completion and balancing phase to avoid gradually walking the state of charge down to a fractional charge with maximum acceptance. The available hours of sunlight tend to limit the time available for charge balancing, and the battery's available capacity is set by the cell with the lowest state of charge, so the idea is to get as close to 100% as possible on the cell with the highest state of charge before the charge rate has to drop to avoid damaging cells.

The details are battery technology dependent, but if the idea is to know the state of charge of a battery, then a more complex formula for charge acceptance may produce a more satisfying result.

Not the answer you're looking for? Browse other questions tagged or ask your own question.