Targets for assignments and worksheets

Creating assignments and worksheets in Quarto and rendering them using Targets

Ken Butler
2024-11-03

Introduction

In my teaching, I create worksheets and assignments for my students. These contain a number of questions based on a small number (usually two or three) scenarios. I like each scenario and its associated questions to live in a separate Quarto file (for ease of moving to another worksheet or assignment later, in case I don’t get as far in class as I had anticipated). I also want the worksheets to be available with and without solutions. (My students see a worksheet without solutions at their tutorial, and they get to try it with a TA available for help. After the tutorials are all done for the week, I post the solutions so that the students can see how they did).

This means that I need to navigate several things:

  1. creating a scenario and its questions
  2. figuring out how to set up the solutions so that they can be rendered or not
  3. setting up a worksheet with several scenarios and their questions and solutions
  4. creating two versions of the rendered document, one with solutions and one without
  5. making all this happen in Targets, respecting the dependency of each worksheet on its constituent scenario files (and maybe datafiles as well, if they are likely to change).

Let’s take these in turn. I’m going to create a baby worksheet called Worksheet 99 which has two scenarios with a couple of questions each, using one familiar and one possibly unfamiliar dataset.

Two scenarios

The first scenario is based on the infamous mtcars dataset. I put this in the file motor-trend.qmd, which looks like this:

## Motor Trend cars

In 1974, the *Motor Trend* magazine collected data on fuel consumption
and other features of 32 different makes of car. The data are available
in the built-in dataset `mtcars`. The variables of interest to us are:

- `mpg`: fuel consumption in miles per US gallon
- `cyl`: number of cylinders in the engine
- `wt`: weight of car, in thousands of pounds.

(@) Make a suitable plot of fuel consumption against weight.

(@) Modify your plot to distinguish cars with different numbers of cylinders by colour.

This is not a self-contained Quarto file: it’s Quarto all right, but it’s designed to be included in another file (which it will be, later). In the Quarto documentation, they recommend giving a file like this a name with an underscore on the front, to make sure it doesn’t get rendered by accident (if, for example, the folder is a Quarto project and you render the whole folder). I’m going to control things with Targets, however, so I’m not going to worry about that.

The other notable feature here is how I label the two questions: the (@) on the front, which will auto-number them from 1 upwards in the final worksheet.1

The second scenario is based on some data on making soap, which lives in soapy.qmd:

## Making soap

A factory makes soap. There are two production lines, `a` and `b`. 
These can be run at different speeds; running the production line faster
produces more soap, but it also produces more scrap (soap that cannot be
sold). Does the amount of scrap differ by production line? Answer the
questions below to find out. The data is in
<https://ritsokiguess.site/datafiles/soap.txt>.

(@) Read in and display some of the data.

(@) Make a suitable plot of the scrap produced and the production line. How do the production lines compare?

(@) Do you get a different story if you include speed in your plot?

This is structured the same way as the first file, and will have three numbered questions when it is rendered.

Adding the solutions

This, I have to admit, I stole more or less wholesale from Nicola Rennie, whose blog post you would do well to read. There are two key ideas:

In the YAML header of a Quarto document, you can have a section called params which supplies some default values for parameters. The example in the Quarto documentation is this:

---
params:
  alpha: 0.1
  ratio: 0.1
---

which sets default values for the parameters alpha and ratio. You access them in the Quarto document through R like this, in an R code block:

params$alpha

You can supply different values by running quarto render with the -P option, like this:

quarto render myfile.qmd -P alpha:0.2 -P ratio:0.3

and then 0.2 and 0.3 will get passed down into your document.

Wait, you say, what YAML block? Neither of our files even have a YAML block. Well, when we get around to making the worksheet itself out of our two scenarios, we’ll have a proper “main” Quarto document that includes our two files, and not only will that have a YAML header with default parameter values in it, but also they will get passed down into our “child” documents with the scenarios in. The parameter we will use will be called hide_answers and will be either true or false.

All right, now to conditional content. Here’s how you hide some content if you are creating an HTML document (from the Quarto docs):

::: {.content-hidden when-format="html"}

Will not appear in HTML.

:::

The ::: marks the beginning and end of a so-called “div block”. Inside the {} on the top line is a class that the text has (being hidden) and an optional condition when it should be hidden (when the document format is HTML).

That’s all fine and wonderful, but we want to make our content hidden when something in R is true (namely, params$hide_answers is TRUE). The way around this is to use inline R code to produce the top and bottom lines of our div block:

at the top, and

at the bottom. The way this works is if params$hide_answers is TRUE, these lines create a div block with the content-hidden class (that is, the text between these two lines is hidden), but if params$hide_answers is FALSE, no div block is created at all, and the text between these two lines is displayed.

Now we have the machinery to add some optionally-displayable text, that is to say, solutions, to our problems. What you do is to add the code that optionally starts the div block at the start of a solution, and the code that optionally ends it at the end. Thus, for example, the Motor Trend question file with solutions2 looks like this:

This process for adding solutions to a file of questions really ought to be called Renniefication.

Making a worksheet

Now that we have scenarios, questions and solutions, we can put together our Worksheet 99. This is how it goes together, with some comments below:

This is, you might say, the end of the story. You render this file once with hide_answers: true to make a worksheet to give to your students, and later you change true to false to make the solutions for them.

However, there is more human intervention here than you might like. Both the question document and the solutions document will be called worksheet_99.html, and you’ll have to remember (or look) to find out whether it currently contains questions or solutions. It would be nice to make two html files, one with just the questions and the other with solutions as well, each with different names like worksheet_99_q.html and worksheet_99_a.html.

The other thing is how to keep everything up to date. If you change either of the included files, you want to be able to re-render worksheet_99.qmd without having to remember to do so. Veteran Fortran programmers like me would solve this with a Makefile. The R way to do this is to use the targets package, which we discuss shortly.

Rendering with parameters

One way to supply parameter values is to put them in params in the YAML block. But you can also supply them on the command line if you render that way, like this:

quarto render worksheet_99.qmd -P hide_answers:true

This puts the questions without solutions into worksheet_99.html. But we can go one step further and set the name of the output file, like this:

quarto render worksheet_99.qmd -P hide_answers:true -o worksheet_99_q.html

Then, to make an HTML file with the solutions as well, you change hide_answers:true to hide_answers:false and change the -o part to -o worksheet_99_a.html.

There is enough repetitive stuff here that I wrote a function to do it:

renderify <- function(fname, ...) {
  ans <- SplitPath(fname)
  qq <- str_c(ans$filename, "_q.html")
  aa <- str_c(ans$filename, "_a.html")
  cmd <- str_c("quarto render ", ans$fullfilename)
  cmd1 <- str_c(cmd, " -P hide_answers:true -o ", qq)
  cmd2 <- str_c(cmd, " -P hide_answers:false -o ", aa)
  olddir <- setwd(ans$dirname)
  system(cmd1)
  system(cmd2)
  setwd(olddir)
  fname
}

I wrote this just before reading Danielle Navarro’s excellent blog post on the fs package, and now I realize that this would have been a great reason to learn about that package. I had, however, gotten this working using SplitPath from the DescTools package, so this is what you get. Also, I realize, now that I look at the code, that it would have benefitted greatly from using glue::glue rather than str_c from stringr.3

Girt af.

Anyway: the function takes as input a filename (of a .qmd file) and:

So now I run cmd1 and cmd2 that I so laboriously constructed, right? Not so fast. When you run quarto render from the command line, the file you’re rendering has to be in the same folder that you currently are. This is not usually the case for me: my project has worksheets and assignments in a subfolder assignments, and when I am running targets that is all controlled from the main project folder. So, very carefully, I change to the subfolder the .qmd is in (saving my previous folder to go back to later), then run my commands, then go back to the folder I was in.

I hope in this way I am safe from having my computer set on fire, although I could undoubtedly stand to learn about the here package too.

Doing this in targets

The (very brief) idea behind targets is that certain of your files (like documents) depend on certain other things (included files, here, or functions or datasets in general). The Targets book has a great intro walkthrough. What you do is to create “targets”, and then have functions that express how targets depend on each other. The definition of the targets lives in a file _targets.R in the project folder. Here is the relevant bit of that:

worksheet99 <- list(
  tar_target(worksheet_99_file, "assignments/worksheet_99.qmd", format = "file"),
  tar_target(motor_trend, "assignments/motor-trend.qmd", format = "file"),
  tar_target(soapy, "assignments/soapy.qmd", format = "file"),
  tar_target(worksheet_99, renderify("assignments/worksheet_99.qmd",
                                      worksheet_99_file,
                                      motor_trend,
                                      soapy))
)

My function renderify is in a file R/functions.R which has been sourced earlier in _targets.R.

First, I create targets for each of my three files: the two question files, and the main worksheet file. Inside tar_target, the first thing is the name of the target you’re making, the second is where the file lives, and the third thing is format = "file". Then, the last target is a function call. As we have seen, renderify creates the two output files for the worksheet, but also serves the double duty of enforcing the dependency of the final worksheet on the targets made from the other three files. Targets knows this because those other three targets are also input to renderify, so if any of those three targets have changed (meaning, any of the files from which those targets are made), the whole worksheet will be re-rendered. The relevant part of targets::tar_visnetwork shows this: target worksheet_99 depends on targets worksheet_99_file, motor_trend, and soapy.

The project from which the above was taken has targets for a whole bunch of worksheets, assignments, tests etc. If you look at targets examples, you will usually see, at the end of the file, a list() that defines every single one of the targets. But for me, this was getting out of hand, so I defined and saved a separate list() for each worksheet or assignment, and then at the end of my _targets.R, I glue them all together into one list with some code like this:

c(worksheet1, worksheet2, worksheet3, worksheet4, 
  worksheet5, worksheet6, worksheet7, worksheet8, 
  worksheet9, worksheet99)

Now, I can run targets::tar_make() and my worksheet will be re-rendered (twice, to get the two output files) if and only if any of the files making it up have changed.

You might be thinking that renderify doesn’t need those other inputs, and you would be quite right: the only thing the function uses is the filename that is the first input. I used ... in my function code to allow arbitrary many other inputs, and these are used only to create the dependency, so that Targets knows what depends on what. This is the cleanest way I could think of.

Results

So maybe now you want to see the result of all of this:


  1. I would actually prefer to have these questions numbered 1(a) and 1(b), but I haven’t figured out how to control this numbering in the way you can in LaTeX.↩︎

  2. Much briefer than my usual solutions, it has to be said.↩︎

  3. Which is really just paste0.↩︎

Citation

For attribution, please cite this work as

Butler (2024, Nov. 3). Ken's Blog: Targets for assignments and worksheets. Retrieved from http://ritsokiguess.site/blogg/posts/2024-11-02-assignments-worksheets-and-targets/

BibTeX citation

@misc{butler2024targets,
  author = {Butler, Ken},
  title = {Ken's Blog: Targets for assignments and worksheets},
  url = {http://ritsokiguess.site/blogg/posts/2024-11-02-assignments-worksheets-and-targets/},
  year = {2024}
}