ETC5523: Communicating with Data

Tutorial 10: One calculation, three stories

Author

Michael Lydeamore

Published

1 October 2001

🎯 Objectives

By the end of this tutorial, you will be able to:

  • build interval solar, grid-flow and battery-state calculations step by step;
  • reuse one reactive calculation across several outputs;
  • generate narrative text from reactive results;
  • create persuasive and sceptical framings without changing the evidence; and
  • audit an interactive communication for misleading differences.

What you will build

One dashboard presents the same household energy scenario in three ways:

  1. Why invest: a salesperson foregrounds bill savings and the value of storage.
  2. Energy balance: a neutral analyst explains consumption, production, imports and exports.
  3. Cautious case: a sceptic foregrounds up-front cost, payback and model limitations.

All three tabs must use the same scenario_data(), metrics() and daily_data() reactives.

Important

Your framing may select and emphasise different consequences. It may not silently change the data, system size, tariffs, battery or calculation.

Preparation

If you completed the workshop, continue with your workshop_app.R: it already contains the tabset and calculation-trace tools used below.

Otherwise, create a folder for the tutorial and download:

Keep the app file and solar_data.csv in the same folder, then open the app and select Run App. If you are continuing from the workshop, retain the Evidence tab while completing the three audience tabs.

The supplied data are already in long form. Each row records half an hour of observed household consumption. The app adds deterministic modelled solar production and a simplified battery dispatch.

1. Build the solar calculation outside Shiny

Before working with reactives, reproduce the calculation for one day.

Run:

worked_day <- consumption_data |>
  filter(date == as.Date("2024-12-06"))

Add these columns one at a time:

  1. interval_hours: infer the interval from the difference between consecutive datetimes;
  2. hour_of_day: combine the hour and minute as a decimal;
  3. daylight: create a curve that is zero outside 6 am–6 pm and peaks at midday;
  4. seasonal: create the summer–winter scaling factor; and
  5. solar_kwh: multiply system capacity, daylight, season and interval length.

Use a 5 kW system.

interval_hours <- median(
  as.numeric(diff(worked_day$datetime), units = "hours"),
  na.rm = TRUE
)

worked_day <- worked_day |>
  mutate(
    hour_of_day = hour(datetime) + minute(datetime) / 60,
    daylight = pmax(sin(pi * (hour_of_day - 6) / 12), 0),
    seasonal = 0.70 +
      0.30 * cos(2 * pi * (yday(datetime) - 15) / 365.25),
    solar_kwh = 5 * daylight^1.5 * seasonal * interval_hours
  )

The interval is 0.5 hours. Multiplying power in kW by 0.5 hours produces energy in kWh for each row.

Check your result:

  • solar production should be zero at midnight;
  • it should peak near midday;
  • no value should be negative; and
  • doubling the system size should double every solar value.

Plot observed consumption and modelled production for the day. Label them so a reader cannot confuse observed and modelled quantities.

2. Move from energy balance to grid flows

Add net demand:

worked_day <- worked_day |>
  mutate(net_demand_kwh = energy_kwh - solar_kwh)

For each row:

  • positive net demand requires energy from the battery or grid;
  • negative net demand is surplus available to charge the battery or export.

First calculate the no-battery case using pmax():

worked_day <- worked_day |>
  mutate(
    grid_import_without_battery_kwh = pmax(net_demand_kwh, 0),
    grid_export_without_battery_kwh = pmax(-net_demand_kwh, 0)
  )

Why is the minus sign needed in the export calculation?

A surplus has negative net demand. Negating it converts the magnitude of that surplus into a positive export quantity. The use of pmax() ensures both flow columns remain non-negative.

3. Trace the battery as a state through time

A battery cannot be calculated independently row by row. Its state at one interval depends on its state at the previous interval.

Start with this deliberately simple sequence:

Interval Consumption Solar Net demand
1 1 0 1
2 1 3 −2
3 1 2 −1
4 1 0 1
5 1 0 1
6 1 0 1

Assume:

  • a 2 kWh battery;
  • an empty initial battery;
  • no charging or discharging losses.

For each row, calculate battery state, grid import and grid export. Capacity cannot exceed 2 kWh and state cannot fall below zero.

Interval Net demand Battery after interval Grid import Grid export
1 1 0 1 0
2 −2 2 0 0
3 −1 2 0 1
4 1 1 0 0
5 1 0 0 0
6 1 0 1 0

Interval 3 exports energy because the battery is already full. Intervals 4 and 5 use stored energy. By interval 6 the battery is empty, so demand returns to the grid.

Now inspect dispatch_battery() in the starter app. Match each part of the function to one row of your manual trace:

  1. read net demand;
  2. calculate energy stored or delivered;
  3. enforce capacity or available state with min();
  4. update state; and
  5. record the remaining import or export.

The app uses the square root of round-trip efficiency for both charging and discharging. Explain why applying 90% twice would not represent 90% round-trip efficiency.

Applying 90% during charging and again during discharging would give 0.9 × 0.9 = 81% round-trip efficiency. Using the square root in each direction gives √0.9 × √0.9 = 0.9.

4. Turn grid flows into costs

For every interval, the baseline buys all observed consumption from the grid:

baseline_cost <- energy_kwh * import_tariff

The scenario pays for imports and receives a credit for exports:

scenario_cost <-
  grid_import_kwh * import_tariff -
  grid_export_kwh * feed_in_tariff

interval_saving <- baseline_cost - scenario_cost

Check the signs using one imported and one exported interval. Explain why exported electricity is not necessarily “wasted”.

An export credit is subtracted from the scenario cost, so it increases the saving relative to the baseline. Exported electricity may be less valuable than self-consumed electricity, but it still has value when the feed-in tariff is positive.

Only after completing the interval calculation should the app sum energy and costs, calculate self-sufficiency, annualise savings and estimate simple payback.

5. Trace the common reactive calculation

Before changing the app, locate these three reactive objects:

  • scenario_data() filters the observed records, adds solar production, dispatches the battery and calculates interval costs;
  • metrics() reduces that scenario to values used in headlines; and
  • daily_data() prepares daily totals for plotting.

For each input below, predict which reactive objects should invalidate:

  1. date range;
  2. solar system size;
  3. import tariff;
  4. battery capacity; and
  5. active tab.

Changing the date range, system size, tariff or battery capacity changes scenario_data(), so the dependent metrics() and daily_data() also invalidate.

Changing only the active tab should not rebuild the scenario. Tabs select a view of the common results; they do not represent different calculations.

6. Build the salesperson framing

The salesperson wants the user to ask:

What do I gain from installing this system?

In the Why invest tab, replace the placeholder with:

h3(textOutput("sales_headline", inline = TRUE)),
textOutput("sales_message"),
plotOutput("sales_plot", height = "430px")

In the server:

  1. create a headline containing the selected period’s bill saving;
  2. mention self-sufficiency and, when included, energy redirected by the battery; and
  3. plot cumulative bill savings over time.

Do not calculate any new scenario inside an output. Use metrics() and daily_data().

One possible headline and message are:

output$sales_headline <- renderText({
  paste0(
    "Put ", scales::dollar(metrics()$saving),
    " back in the household budget"
  )
})

output$sales_message <- renderText({
  battery_text <- if (isTRUE(input$include_battery)) {
    paste0(
      " The battery redirects about ",
      scales::number(metrics()$battery_shift, accuracy = 1),
      " kWh that would otherwise have been imported from the grid."
    )
  } else {
    ""
  }

  paste0(
    "Over the selected ", metrics()$days,
    " days, the modelled system supplies ",
    scales::percent(metrics()$self_sufficiency, accuracy = 1),
    " of consumption without grid imports.",
    battery_text
  )
})

The cumulative-savings plot can be:

output$sales_plot <- renderPlot({
  ggplot(daily_data(), aes(date, cumulative_saving)) +
    geom_area(fill = "#72BF44", alpha = 0.35) +
    geom_line(colour = "#2E7D32", linewidth = 1) +
    scale_y_continuous(labels = scales::label_dollar()) +
    labs(
      x = NULL,
      y = "Cumulative bill saving",
      subtitle = "Avoided import costs minus foregone export credits"
    ) +
    theme_minimal(base_size = 15)
})

This framing is positive, but it still labels what the saving includes.

7. Build the cautious framing

The sceptic wants the user to ask:

What could make this investment unattractive?

In the Cautious case tab, add:

h3(textOutput("sceptic_headline", inline = TRUE)),
textOutput("sceptic_message"),
plotOutput("sceptic_plot", height = "430px")

Create:

  1. a headline reporting simple payback;
  2. text that names at least two limitations; and
  3. a plot of cumulative savings after subtracting the up-front cost.

Again, use the existing common reactives.

One possible headline and message are:

output$sceptic_headline <- renderText({
  if (is.finite(metrics()$payback)) {
    paste0(
      "Simple payback is approximately ",
      round(metrics()$payback, 1),
      " years"
    )
  } else {
    "The selected assumptions do not produce a positive annual saving"
  }
})

output$sceptic_message <- renderText({
  paste0(
    "The headline saving excludes the up-front cost of ",
    scales::dollar(metrics()$capital_cost),
    ". It also assumes tariffs, consumption and modelled production ",
    "continue unchanged."
  )
})

The net-position plot can be:

output$sceptic_plot <- renderPlot({
  ggplot(daily_data(), aes(date, net_position)) +
    geom_hline(
      yintercept = 0,
      linetype = "dashed",
      colour = "#555555"
    ) +
    geom_line(colour = "#9C2F2F", linewidth = 1) +
    scale_y_continuous(labels = scales::label_dollar()) +
    labs(
      x = NULL,
      y = "Cumulative position after up-front cost",
      subtitle = paste(
        "Simple cash position; financing, maintenance",
        "and degradation are excluded"
      )
    ) +
    theme_minimal(base_size = 15)
})

8. Audit the three views

Run the app and record the following for each tab:

View Main claim Foregrounded measure Important context left secondary
Why invest
Energy balance
Cautious case

Then test at least three combinations of:

  • solar system size;
  • battery included or excluded;
  • import and feed-in tariffs; and
  • date range.

Check that:

  1. the text updates when the inputs change;
  2. the three views remain numerically compatible;
  3. no tab suggests that observed consumption is modelled;
  4. no tab presents modelled production as observed; and
  5. battery claims disappear when the battery is excluded.

9. Find the ethical boundary

Choose one change below and implement it temporarily:

  • show only summer months in the salesperson view;
  • remove the up-front-cost line from the cautious view;
  • use different tariffs in different tabs; or
  • rename “modelled solar production” as “solar production”.

Discuss with a partner:

  1. Does the change merely guide attention?
  2. What would a reasonable user infer?
  3. What label or disclosure would make the choice defensible?
  4. Should the change remain in the dashboard?

Restore a defensible version before continuing.

Tip

A transparent subset can be legitimate. A hidden change to the calculation is not.

10. Test the non-ideal states

Try to produce:

  • a very short date range;
  • a small solar system and expensive battery;
  • a zero feed-in tariff; and
  • a scenario with no battery.

For each state, ask:

  • Does every output still make sense?
  • Does the app explain an awkward result?
  • Does the user know what to change next?

Add one validate(need()) message or one conditional sentence that improves a weak state.

For example, you could guard a plot that needs at least seven days:

validate(need(
  metrics()$days >= 7,
  "Choose at least seven days to compare the trend."
))

Or make the salesperson message explicit when no battery is selected:

if (!isTRUE(input$include_battery)) {
  battery_text <- " No battery is included in this scenario."
}

11. Writer’s note

Complete both sentences:

My strongest legitimate framing choice is…

It would become misleading if…

Extension: let the evidence travel

Add a fourth tab containing:

downloadButton("download_data", "Download common scenario data")

Connect it to scenario_data() with downloadHandler(). The downloaded data should include observed consumption, modelled production, battery state, grid imports, grid exports and interval savings.

The complete implementation is available in the finished dashboard.