Java Extensions

Inserting Ingredients

Introduction

In a previous application note we examined how to define the hardware, pumps, and nozzle in a basic dispenser. We discussed how to define pump "intents", which allow pumps to pour ingredients using named sequences.

One constraint on ingredient pouring is that by default KOS will not allow a pump to be run dry. Since pumps don’t typically have sensors to detect actual ingredients, this is enforced by checking if an ingredient is assigned to a pump. This leads to the question of how to assign ingredients.

This application note describes the abstractions that exist within KOS to support the logical insertion of an ingredient into a device. Ingredient insertion requires only ingredient IDs, so this note ignores the design of actual ingredient metadata.

In a real device, the data associated with an ingredient often impacts the insertion process, defining which types of pumps are compatible with ingredients, how ingredients can be combined to pour a final beverage, and even impacting pump calibration. This note defers how to assign ingredients persistently to another note.

Overview

Some dispense devices have only a passing concept of an ingredient: A technician enters the name of an ingredient during setup and then the beverage is ready to pour. While KOS can handle these simple devices, its infrastructure also provides a consistent developer experience, handling the most basic to the most complex devices. This allows applications to be written in a consistent way across a family of devices.

Freestyle micro-dosing is one of the more complex models when it comes to ingredients. Instead of BiB ingredients that are effectively plumbed into the device, micro-dosing utilizes smart cartridges, much like ink cartridges in a printer. Freestyle cartridges utilize RFID tags to identify ingredients, expiration dates, volume remaining, and other information. These cartridges are inserted into slots in shelves or towers, and are detected on-the-fly via RFID scanning. This allows many insertion processes to be triggered by physical events rather than user-software interactions.

Due to the nature of cartridges, there can be a great deal of business logic required during the insertion process. Cartridges can be inserted into the wrong slots, may be sold out before being inserted, may be expired, may be moved across dispensers, may have compatibility constraints with associated pumps, and so on. This is further complicated by the existence of double cartridges that contain two different ingredients, which requires validation of both ingredients as a group. The ingredient insertion process within KOS provides several abstractions and extension points to allow applications to scale from simple ingredient assignment to the complexities of a micro-dosed architecture.

Abstractions

There are a number of abstractions in KOS that allow it to handle a wide variety of use cases. The following sections outline these abstractions, how they relate to each other, and what their physical equivalents are. These are combined in later sections to actually insert ingredients.

Containers and Slices

All insertions start with a container. A container logically holds fluids that can be pumped. In micro-dosing terms, a container is a physical Freestyle cartridge. In a legacy environment, a container is a logical representation of a physical BiB box sitting on a shelf.

Every ingredient comes from a container, whether there is a physical equivalent or not. For example, water must also come from a logical container even though in a physical device water is simply plumbed from the wall.

Containers within KOS can have attributes, although in many cases the only attribute is the ingredient ID. While Freestyle cartridges have a broad assortment of information encoded in the RFID tag, even passive containers may provide attributes that are transferred to the software representation. For example, a BiB box may have a sticker with an expiration date. Freestyle cartridges also contains stickers that contain much of the same data available in the RFID tag, in case it is damaged. Part of the KOS insertion process allows for a mechanism where this information is added to the cartridge and utilized upstream in application code.

While most physical containers have a single ingredient, it is possible for a container to have multiple ingredients. For example, Freestyle’s double cartridges take up two slots in a shelf, and have two different ingredients in the same physical container. In KOS these are referred to as slices.

Every container has at least one slice. Even water, which doesn’t have a physical representation of a container, let alone a slice, still has a slice in KOS. In fact, a container doesn’t even have a concept of an ingredient, it only has a concept of slices, and each slice contains an ingredient. When a container reports the available ingredients, it’s the aggregation of ingredients from the associated slices.

Since a slice contains an ingredient and can have associated metadata, it’s possible for slices to behave somewhat independently of each other. But as entities within a container, slices are bound by container lifecycles. For example, removing a container causes all slices to be removed. Likewise, much of the validation performed during the insertion process is applied at the container level, as it doesn’t make sense to only insert half of a container.

Holders and Pumps

While containers and slices represents what is being inserted, holders and pumps represent what is being inserted into.

A holder is a logical representation of something like a slot in a shelf that a cartridge is inserted into. In many devices there is no physical equivalent to a holder, but in some systems the holder physically exists and can have attributes and capabilities. For example, many Freestyle dispensers use BiB non-nutritive sweetener (NNS) installed into a tray in the dispenser. This tray only accepts NNS and has both a sensor and indicator light. Other Freestyle dispensers have slots that accept micro-dose cartridges and include indicator lights to show the status of the slot or cartridge.

Even if a device doesn’t contain any physical equivalents of a holder, the logical holder still provides a key function which is a unique name for the connection between a container and pumps. In a simple device, a single BiB may connect to a single pump, therefore the name of the pump is meaningful to the user for operations like indicating sold out or ingredient assignment. However, some devices have multiple pumps connected to a single ingredient, in which case using the pump names can be confusing. Furthermore, KOS supports multiple nozzles, and pumps may be named similarly between nozzles to facilitate documenting nozzle connections.

Since there is generally a need to connect a distinct set of ingredients to a distinct set of connections in a device, holders are used to facilitate this mapping as they are globally scoped, unlike pumps which are relative to a nozzle. This makes holders the perfect abstraction for dashboards, setup utilities, and so on. This also scales well when there are multiple nozzles, multiple pumps per holder, and multiple instances of the same ingredient in a device.

As described, if a holder is the entity that accepts a container slice, it can also be thought of as the mounting point for pumps. That is, since pumps are attached to a holder at the time the Assembly is created, any ingredient connected to the holder can be poured by the associated pumps. While many devices only have a single pump per holder, there are various use cases where a holder may contain more than one pump:

  • Freestyle dispensers typically use multiple pumps for ingredients that require higher flow rates than a single pump can provide, such as NNS. In this case, all the pumps act together to form a virtual pump with a higher overall flow rate.

  • Consider a micro-dosed dispenser with one set of shelves but two independent heads. Each slot in the shelf has two pumps, one plumbed to the left head, the other to the right head. These pumps operate independently but share container/holder attributes. For example, if a container is sold out while pouring on the left head, the right head is also sold out even if it wasn’t pouring.

These two examples also illustrate the complexity around assigning an ingredient and making it ready for use. Consider a case where replacing an ingredient requires a prime process for a pump to be usable. In the first example above, all three pumps must be primed before the ingredient in the holder is usable because all three pumps are used in unison. If any one pump is not primed, then the other two pumps are effectively unusable. In the second example, priming one pump can make that pump usable since it operates independently of the other. This means one head of the dispenser may be usable while the other is not. This has the added complexity of only allowing priming of pumps connected to the head the user is standing in front of, in case a customer is standing in front of the other.

The goal of the abstractions outlined above is to allow a single programming model to handle a wide variety of solutions without the developer needing to write new infrastructure or use drastically different concepts. This allows a solution for a straightforward Legacy+ device to be carried forward to a micro-dosed or distributed architecture.

Insertion Service

Within KOS, all ingredient insertions are performed using InsertionService. This service provides a very specific function which is to coordinate insertion requests. For example, it does not handle persistent ingredient mappings, which is provided by other services on top of InsertionService. The sections below describe the phases of insertion, how the insertion pipeline works, and how to customize the pipeline.

Insert request

The insertion process starts with an insert request by calling insert() with a container and corresponding list of holders. Within the service this creates an InsertRequest object, which is placed into a pending list and then the request is processed. Some requests may be successful on the first attempt, but it is common that they fail. When they fail, they remain in the pending list and are periodically retried until they are either successful or they are removed by virture of removing the container from the service or inserting a new container into the same holders. Because insertions can end up in a retry list, the insert call for the service does not indicate success or failure, simply that the request was successfully submitted to the service.

What does it mean for an insert request to fail? On a simple device a BiB connects to a hose which connects to a pump. What can fail in software when all the physical components are properly connected? By default, InsertionService successfully inserts all requests, but a developer can add business logic to the pipeine, in the form of filters, which can block insertions. Here are a few common examples of what filters might check for:

Unknown ingredients

It is common that dispenser software has a list of known ingredients along with corresponding metadata such as icons, names, calibration ratios, and so on. If the inserted container has an unknown ingredient ID, then it can’t be displayed properly in the UI, so a filter may choose to block it.

Expired container

Freestyle cartridges contain machine-readable expiration dates. If an expired ingredient is inserted, then we don’t want to pump it into the line.

Calibration required

If the ratio indicated by the ingredient data doesn’t match the calibration of the pump, a filter may block the insertion until calibration has been performed on the pump.

These are just a few examples. Freestyle cartridges typically have about a dozen rules and policies that are enforced during the insertion process.

Insertion pipeline

As mentioned, InsertionService allows all insertions by default. Applications can add filters to the service which analyze each request and block the insertion process if necessary. These filters make up what is referred to as the insertion pipeline.

When constructing an insertion pipeline, the filters are called in the order they are inserted. This order can have significant implications and should be carefully considered. For example, consider the expired container and calibration required filters mentioned above. If the calibration filter is run before the expiration filter, the calibration filter may cause the user to re-calibrate the pump, in the process filling the line with ingredient, only to realize after calibration that we just filled the line with an expired ingredient.

When an insert request is submitted to the pipeline, the request is passed to each filter in order until either a filter blocks the request or it passes all the filters. If all filters are passed successfully, then the insertion is processed. If any filter blocks the request, then the insertion request is placed back in the retry queue until the block is removed.

A filter will generally block an insert request by creating a Trouble indicating the reason for the block. For example, the unknown ingredient filter may create an UnknownIngredientTrouble. Similarly, a filter that requires the pump to be calibrated might create a CalibrationRequiredTrouble, which is removed once the calibration is complete. The act of removing the Trouble automatically causes the insert request to be processed again.

It is common for filter developers to create new Troubles to go with their filter so that the filter can block the request as needed. This application note does not delve into the details of creating and managing Troubles, but at a high level Troubles should be designed so that they require the user to complete a process, and the act of completing the process will remove the Trouble.

For example, consider a filter that is designed to have users enter the expiration date from the BiB. A Container subclass can be created with an expriationDate property and the filter can block the insert if the value is null. The block can be in the form of a new Trouble called ExpirationDateTrouble which contains enough information that a UI can tell the user which BiB the Trouble is for (the holder/ingredient name, for example). The user would then use the UI to enter the code which would cause the Trouble to be removed, and thus trigger another attempt at the filter pipeline, this time passing the filter since the expirationDate property would not be null. The filter could also check the date now that it’s provided, or another filter in the pipeline could check the expiration date and block the request again.

Filters are generally simple to implement, as most are stateless. In the event that a filter needs to carry state across multiple calls with the same request, the filter can attach filter-specific data to the request. Generally this is not required, and a filter need not even be aware of previously created Troubles. On each pass through the filter, the filter is free to generate the same Trouble over and over. All duplicate Troubles are managed automatically by InsertionService.

It’s important to be aware that KOS may pre-install filters in the insertion pipeline out of the box. For example, KOS provides an ingredient filter that will not allow any insertion unless the ingredient ID matches an ingredient installed in the IngredientService. While these cannot be removed, they can be disabled using the config system. That said, they exist to protect the system, therefore production systems should run this filter.

Filters

This section examines the InsertionFilter in some detail. While most filters are simple, some are quite complex. Let’s examine a simple UnknownIngredientFilter.

Minimal UnknownIngredientFilter class:
public class UnknownIngredientFilter extends InsertionFilter<InsertionFilterConfig> {

    public UnknownIngredientFilter() {
        super("unknownIngredientFilter");
    }

    @Override
    public void checkRequest(InsertRequest request) {
    }

    @Override
    public void checkPumpRequest(InsertPumpRequest request) {
    }
}

Two notes about the filter:

  • Every filter has a name. This is used to form a handle path to the filter so that it can be configured. The name should follow handle path naming guidelines.

  • Every filter is configurable with a configuration object that extends InsertionFilterConfig. This base class has a single property called enabled. When disabled, this filter is skipped. If a filter needs additional configuration data it should extend InsertionFilterConfig.

There are two check methods, and while both are intended to allow a filter to block a request, they have their own lifecycles:

checkRequest()

This checks the entire request as a whole. For example, is there an issue with the container or the ingredients in the container? Is there a restriction around replacing a new ingredient over an old ingredient? Is the ingredient compatible with the pump? These can be thought of as validation checks that would block the insertion before actually using the ingredient with the pump.

checkPumpRequest()

This is called only if checkRequest() does not block the insertion request. The insert request will then be broken down into one or more pump insert requests, one per pump the container is connected to. This is used to enforce per-pump policy. When a pump request passes this check, it is considered usable for beverage pouring. This means that using this style of check can allow some pumps to be used, but can keep other pumps blocked.

In most systems where a holder contains a single pump, checkPumpReqeust() can be ignored. For example, consider a PrimeFilter that requires each pump to be primed before use. If there is only a single pump per holder, then this will create a PrimeTrouble for the one pump and when complete, will allow the filter to pass. It makes no difference whether this is in the main checkRequest() or checkPumpRequest() callback, as they are effectively equivalent.

However, in the example of a two-headed dispenser above, it’s not only possible to get one pump primed without the other, but likely required since the user can only be standing in front of one head or the other, so they can only prime the head they’re standing in front of in case a customer is in front of the other. In this case, there are two pump insert requests, and each one can generate a PrimeTrouble for the associated pump. When the user completes the prime for the pump attached to the nozzle that is part of the head they are standing in front of, then that pump becomes available for beverage pouring. They can then switch to the other head and complete the prime there to have both pumps available.

When using checkPumpRequest(), it’s important to remember that it won’t be called until checkRequest() is successful, and once checkRequest() is successful, it will no longer be called on subsequent retries, only the checkPumpRequest() calls will be made.

Since our filter is only checking for unknown ingredients, we can simply put our logic into checkRequest() as shown below:

Writing code for the checkRequest() method:
public class UnknownIngredientFilter extends InsertionFilter<InsertionFilterConfig> {
    @Autowired
    private IngredientService service;

    public UnknownIngredientFilter() {
        super("unknownIngredientFilter");
    }

    @Override
    public void checkRequest(InsertRequest request) {
        // If an ingredient is unknown, then add a Trouble:
        for (ContainerSlice slice : request.getContainer().getSlices()) {
            if (service.getIngredient(slice.getIngredientId()) == null) {
                request.block(new UnknownIngredientTrouble(slice));
            }
        }
    }
}

The minimal implementation of UnknownIngredientTrouble is below:

Create the UnknownIngredientTrouble class:
public class UnknownIngredientTrouble extends ContainerSliceTrouble {
    public UnknownIngredientTrouble(ContainerSlice slice) {
        super(slice, PourAvailability.Type.INGREDIENT);
    }
}

The Trouble simply extends ContainerSliceTrouble, which provides standard information about a slice in a container. This base class can optionally block either beverage or ingredient pouring for the slice. Since we don’t know the ingredient, we don’t want any pumps to use it, therefore we specify PourAvailability.Type.INGREDIENT to block ingredient pouring (which implies no beverage pouring).

This doesn’t show how the Trouble is removed, which is beyond the scope of this application note. KOS provides unknown ingredient detection out of the box via IngredientService, so this is just an illustration of what a minimal filter looks like.

Listener interfaces

Since insertions are inherently asynchronous, many times requiring processes to be performed in order to pass through the insertion pipeline, there are listener interfaces available to be notified when insertions complete. There are two listener interfaces available:

InsertionHolderListener

This interface provides events when an insertion request is first created and the container is attached to the holders. It will also notify when the container is removed from the holders either due to removal of the container or a new container being inserted over top of the current container.

InsertionPumpListener

This interface generates pump level events when the insertion process for a given pump completes. Generally speaking, this implies the pump is available for beverage pouring, although non-filter Troubles may be generated to block the pump. This is also notified when a container is removed from a particular pump after it was successfully inserted.

These interfaces are commonly used to trigger updates related to available brandset as ingredients are being inserted and removed. It’s worth noting that in a legacy style device with BiB based ingredients, insertions only occur at startup when previously mapped ingredients are inserted again, or when an ingredient is assigned / unassigned via a technician user interface. When BiB ingredients sell out, they aren’t typically removed / re-inserted. More commonly, the user either switches the box and a sensor detects the switch, or a button in the user interface is used to tell the hardware the ingredient has been replaced. This is much different from micro-dose architectures where ingredients are physically inserted and removed during normal use.

Intrinsic ingredients

Certain ingredients in a dispenser are "intrinsic", such as plain and carbonated water. These are typically plumbed to dedicated pumps and connected to an essentially infinite ingredient source. In some cases these ingredients are necessary to process other ingredients. For example, microdose ingredients tend to need dilution during common operations like priming. For these types of ingredients, KOS provides the concept of an intrinsic ingredient. The easiest way to leverage this is to use IntrinsicContainer to insert water and carb as part of the Assembly. Intrinsic containers bypass all filters and are immediately inserted. The IntrinsicContainer is also a locked container, so once it is inserted it can never be removed. This prevents accidental assignment of some other ingredient on the water pump from replacing the water ingredient.

Intrinsic ingredients must be installed in the postInstall() callback of the Assembly to ensure that all the components created in the assembly are installed and ready for use. Here is an example of installing water and carb in a demo assembly from a previous application note:

Installing water and carbonation as intrinsic ingredients:
public class DemoAssembly extends CoreAssembly {
    @Autowired
    private InsertionService insertionService;
    private DemoBoard board;

    public DemoAssembly() {
        // Create a nozzle and add it to the assembly:
        Nozzle nozzle = new Nozzle(this, "myNozzle");
        add(nozzle);

        // Create a board and add it to the assembly:
        board = new DemoBoard(this, "myBoard");
        add(board);

        // Add the pumps from the board to the nozzle:
        nozzle.add(board.getWaterValve());
        nozzle.add(board.getCarbValve());
        nozzle.add(board.getSyrups());

        // Create the water holders:
        add(new Holder(this, "PW", board.getWaterValve());
        add(new Holder(this, "CW", board.getCarbValve());

        // Create syrup holders:
        int i = 1;
        for (Pump<?> pump : board.getSyrups()) {
            add(new Holder(this, "S" + (i++), pump));
        }
    }

    @Override
    public void install() {
    }

    @Override
    public void postInstall() {
        // Insert water and carb as intrinsic ingredients:
        insertionService.insertIntrinsic(DemoIngredient.WATER, board.getWaterValve().getHolder());
        insertionService.insertIntrinsic(DemoIngredient.CARB, board.getCarbValve().getHolder());
    }
}

While intrinsics are a convenient way to get base ingredients installed, care should be taken when using intrinsics:

  • Since intrinsics are locked, it is impossible to remove them. This makes them impractical for any ingredients that can be swapped during the life of the device.

  • Intrinsics are not subject to filters. While it might be tempting to use intrinsics for dedicated pumps like HFCS or NNS, there may be value in making these regular insertions as it can force initialization/calibration procedures in line with other pumps.

While intrinsics should be used sparingly in production code, they are a convenient way to quickly insert ingredients early in development just to get some data to test with.

Previous
Next
On this page
Java Development
Seamlessly transition from Legacy+ systems to Freestyle microdosing and advanced distributed dispense systems.
UI Development
Using KOS SDKs, integrating Consumer and Non-consumer facing UIs becomes seamless, giving you less hassle and more time to create.
Video Library
Meet some of our development team, as they lead you through the tools, features, and tips and tricks of various KOS tools.
Resources
Familiarize yourself with KOS terminology, our reference materials, and explore additional resources that complement your KOS journey.
Copyright © 2024 TCCC. All rights reserved.