ETC5523: Communicating with Data

One dataset, three stories

Lecturer: Michael Lydeamore

Department of Econometrics and Business Statistics


By the end of today

You will be able to:

  1. build one shared reactive model that feeds several views;
  2. use tabs, conditional UI and reactive text to guide an argument; and
  3. distinguish legitimate emphasis from changing or hiding the evidence.

Important

The technical goal is not three separate apps. It is one calculation, communicated three ways.

The same evidence can support different stories

Salesperson

“A battery helps you use more of the energy your panels generate.”

Neutral analyst

“Savings depend on production, demand, tariffs and timing.”

Sceptic

“Up-front costs remain visible long after the first bill saving.”

All three statements can be true at the same time.

First establish the common evidence

Consumption is observed. Solar production is a transparent, simplified model.

Build the scenario one column at a time

Begin with energy measured every half-hour

The supplied data are already in long form:

consumption_data
# datetime              energy_kwh
# 2024-07-15 00:00:00       0.0312
# 2024-07-15 00:30:00       0.0437
interval_hours <- median(
  as.numeric(diff(consumption_data$datetime), units = "hours")
)
# 0.5

The interval length is needed because kW measures power, while each row requires kWh of energy.

The solar model converts capacity into interval energy

For each half-hour:

\[ \text{solar kWh} = \text{system kW} \times \text{daylight shape} \times \text{seasonal factor} \times \text{interval hours} \]

hour <- hour(datetime) + minute(datetime) / 60
daylight <- pmax(sin(pi * (hour - 6) / 12), 0)
seasonal <- 0.70 + 0.30 * cos(2 * pi * (yday(datetime) - 15) / 365.25)
solar_kwh <- system_kw * daylight^1.5 * seasonal * interval_hours

This is a teaching model, not a site-specific production forecast.

Inspect each part before combining it

Daylight

Zero outside 6 am–6 pm; highest near midday.

Season

The factor ranges from 0.4 to 1.0 and peaks in the Australian summer.

System and interval

A larger system scales production; multiplying by 0.5 converts half-hourly power to energy.

Tip

Keep these intermediate columns while developing. Remove them only after the calculation has been checked.

Net demand determines the direction of flow

net_demand_kwh <- energy_kwh - solar_kwh

Positive net demand

Consumption exceeds solar production.

Energy must come from the battery or grid.

Negative net demand

Solar production exceeds consumption.

The surplus can charge the battery or be exported.

Without a battery, split net demand at zero

grid_import_without_battery_kwh <- pmax(net_demand_kwh, 0)
grid_export_without_battery_kwh <- pmax(-net_demand_kwh, 0)
Net demand Grid import Grid export
0.8 kWh 0.8 kWh 0 kWh
−1.2 kWh 0 kWh 1.2 kWh

The sign tells us the direction; pmax() keeps both flow columns non-negative.

A battery makes the calculation sequential

The state of charge at interval i depends on interval i − 1.

For every row, in time order:

  1. calculate net demand;
  2. use surplus to charge, subject to capacity and charging losses;
  3. use stored energy to meet demand, subject to discharging losses;
  4. record the remaining grid import or export; and
  5. carry the new state of charge to the next row.

Important

This is why battery dispatch cannot be calculated with one independent mutate() expression.

Dispatch one interval at a time

one_way_efficiency <- sqrt(round_trip_efficiency)
state <- 0

for (i in seq_len(nrow(data))) {
  net <- data$net_demand_kwh[i]

  if (net < 0) {
    surplus <- -net
    stored <- min(surplus * one_way_efficiency, capacity_kwh - state)
    state <- state + stored
    grid_export[i] <- surplus - stored / one_way_efficiency
  } else {
    delivered <- min(net, state * one_way_efficiency)
    state <- state - delivered / one_way_efficiency
    grid_import[i] <- net - delivered
  }
}

The square root applies half of the round-trip loss while charging and half while discharging.

Trace one day through the battery

Selected intervals from 6 December 2024; 10 kWh battery, 90% round-trip efficiency.
Time Consumption Solar Net Battery Import Export
00:00 0.16 0.00 0.16 0.00 0.16 0.00
06:00 0.05 0.00 0.05 0.00 0.05 0.00
09:00 0.04 1.39 -1.35 3.71 0.00 0.00
12:00 0.21 2.34 -2.12 10.00 0.00 2.12
15:00 0.11 1.39 -1.28 10.00 0.00 1.28
18:00 0.16 0.00 0.16 9.51 0.00 0.00
21:00 0.34 0.00 0.34 7.27 0.00 0.00

Read across a row, then down the Battery column: that is the state carried through time.

Tariffs turn energy flows into interval costs

baseline_cost <- energy_kwh * import_tariff

scenario_cost <-
  grid_import_kwh * import_tariff -
  grid_export_kwh * feed_in_tariff

interval_saving <- baseline_cost - scenario_cost

Note

A feed-in payment reduces the scenario cost. It does not make exported energy “wasted”.

Summaries come after the interval calculation

metrics <- list(
  consumption = sum(energy_kwh),
  production = sum(solar_kwh),
  grid_import = sum(grid_import_kwh),
  grid_export = sum(grid_export_kwh),
  saving = sum(interval_saving),
  self_sufficiency = 1 - sum(grid_import_kwh) / sum(energy_kwh)
)

Simple payback then compares an annualised saving with up-front cost.

Warning

Annualising a short or unusual period can produce an unstable payback estimate.

Framing changes attention—not the calculation

We may legitimately change:

  • the comparison placed in the foreground;
  • the time window or summary, if it is clearly labelled;
  • annotations, ordering, colour and language; and
  • the question the view helps the user answer.

Warning

We must not quietly change tariffs, system size, battery assumptions or the underlying records between tabs.

Build one source of truth

A shared reactive prevents three versions of reality

scenario_data <- reactive({
  consumption_data |>
    filter(date >= input$date_range[1],
           date <= input$date_range[2]) |>
    add_solar(system_kw = input$system_kw) |>
    dispatch_battery(
      capacity_kwh = input$battery_kwh,
      round_trip_efficiency = input$battery_efficiency
    )
})

Every plot, headline and table calls scenario_data().

The dependency graph is the design

Inputs

  • date range
  • system size
  • tariffs
  • battery assumptions

Shared model

  • solar production
  • battery dispatch
  • grid import/export
  • costs and savings

Views

  • salesperson
  • neutral analyst
  • sceptic
  • downloadable evidence

Tip

When a number differs across views, the first place to look is the dependency graph.

Derive the claims from the model

metrics <- reactive({
  df <- scenario_data()

  tibble(
    grid_import_kwh = sum(df$grid_import_kwh),
    grid_export_kwh = sum(df$grid_export_kwh),
    bill_without_system = sum(df$energy_kwh) * input$import_tariff,
    bill_with_system = sum(df$grid_import_kwh) * input$import_tariff -
      sum(df$grid_export_kwh) * input$feed_in_tariff
  ) |>
    mutate(saving = bill_without_system - bill_with_system)
})

The narrative is calculated, not typed around a screenshot.

Turn calculations into three views

Reactive text makes the framing explicit

output$sales_message <- renderText({
  glue::glue(
    "The selected system avoids {scales::dollar(metrics()$saving)} ",
    "in electricity costs over this period."
  )
})

output$sceptic_message <- renderText({
  glue::glue(
    "Simple payback is approximately ",
    "{round(metrics()$payback, 1)} years."
  )
})

The code selects a consequence; it does not invent a result.

The salesperson foregrounds avoided cost

Question: “What do I gain?”

  • lead with cumulative bill savings;
  • show how the battery redirects surplus production;
  • use confident, specific language; and
  • keep costs and assumptions accessible.

Note

Persuasion is not permission to hide the feed-in tariff, capital cost or model limitations.

The neutral view explains the mechanism

Question: “What is happening to the energy?”

  • show consumption, solar production and grid imports together;
  • report imports, exports and self-sufficiency;
  • state which quantities are observed and modelled; and
  • make the selected period and tariffs visible.

This is the reference view against which the other framings can be checked.

The sceptic keeps the opportunity cost visible

Question: “What could make this unattractive?”

  • subtract up-front costs from cumulative savings;
  • highlight grid dependence during low-production periods;
  • show the simple payback implied by the same assumptions; and
  • distinguish uncertainty from evidence against the system.

“Cautious” should mean conservative interpretation—not a different dataset.

Three headlines, one model

Why invest

“Save $X over the selected period.”

Energy balance

“Supply Y% of consumption without grid imports.”

Cautious case

“Simple payback is approximately Z years.”

The numbers X, Y and Z come from the same reactive model.

Model the battery honestly

Surplus is not automatically a battery saving

Without a battery model, we can calculate only:

surplus solar generation that is not consumed at the time it is produced.

Battery savings also require:

  • storage capacity;
  • charging and discharging losses;
  • state of charge; and
  • remaining grid imports and exports.

Conditional controls reveal relevant assumptions

checkboxInput("include_battery", "Include a battery", TRUE)

conditionalPanel(
  condition = "input.include_battery",
  numericInput("battery_kwh", "Usable battery capacity (kWh)",
               value = 10, min = 1, max = 30),
  sliderInput("battery_efficiency", "Round-trip efficiency",
              min = 0.70, max = 1, value = 0.90)
)

Hide irrelevant controls, not inconvenient evidence.

Design for incomplete and awkward states

A blank output is not an explanation

scenario_data <- reactive({
  req(input$date_range)

  df <- consumption_data |>
    filter(date >= input$date_range[1],
           date <= input$date_range[2])

  validate(need(nrow(df) > 0,
                "No records match this period. Try a wider date range."))

  build_scenario(df, input)
})

Validation returns control to the user.

Every state needs a useful next action

State Useful message
Waiting Choose a period and system size.
Empty No records match. Try a wider range.
Invalid Battery capacity must exceed zero.
Failed Check the input data format and try again.

Download the evidence, not merely the picture

output$download_data <- downloadHandler(
  filename = function() paste0("solar-scenario-", Sys.Date(), ".csv"),
  content = function(file) {
    readr::write_csv(scenario_data(), file)
  }
)

A reader should be able to inspect the common calculation behind every frame.

Demo and discussion

Watch what changes—and what does not

During the demo, track four things:

  1. Which inputs invalidate the shared model?
  2. Which numbers remain identical across tabs?
  3. Which design choices make each account feel persuasive?
  4. Where could emphasis become misleading?

Exit ticket

Choose one difference between the salesperson and sceptical views.

Complete both sentences:

This is a legitimate framing choice because…

It would become misleading if…

Week 10 summary

  • One shared reactive model keeps the evidence consistent.
  • Tabs organise competing questions, not competing calculations.
  • Reactive text turns computed results into explicit claims.
  • Conditional UI reveals assumptions when they become relevant.
  • Helpful states and downloadable evidence make persuasion inspectable.

Important

The dashboard’s argument may change. Its underlying evidence must not.