Define a descriptive scenario
In this tutorial you will learn how to create a scenario to simulate the historic performance of the example business and produce descriptive insights, you will:
- Create sources for procurement, sales, and supplier data from JSON files.
- Use the
PipelineBuilder
to process and transform data streams from the sources, defining fields and output keys. - Create functions to generate key dates related to sales and procurement.
- Define resources such as cash, liability, inventory, etc., mapping them from values or data streams.
- Define processes for sales, receiving goods from suppliers, paying suppliers, and procurement.
- Build a reporter process to generate hourly reports on cash, liability, and inventory.
- Create a descriptive scenario to simulate historic business outcomes, incorporating resources and processes.
- Build layouts for graphs displaying cash balance, inventory level, and liability over time.
- Construct a dashboard with tabs for different layouts, a header displaying key performance indicators.
This lesson will assume that you have an empty project and asset which you can to deploy to a workspace named 04_03_01_define_a_descriptive_scenario
with the following command:
edk template deploy -ycw 04_03_01_define_a_descriptive_scenario
Data files
To build the solution for the example business, some pre-defined data can be used. You will use data provided, which can be found in the data
directory in the reference project.
Procurement data
The procurement data will be in a JSONL format describing the orders over time as the date/time and the supplier name, you can see a sample below:
{"date":"2023-10-29T17:00:00.000Z","supplierName":"Josh's Sausages"}
{"date":"2023-10-30T17:00:00.000Z","supplierName":"Josh's Sausages"}
Supplier data
The supplier data will be in a JSONL format describing the individual suppliers and associated information, you can see a sample below:
{"supplierName":"Bob's Dogs","paymentTerms":1,"leadTime":1,"unitCost":2.05,"unitQty":"250"}
{"supplierName":"Josh's Sausages","paymentTerms":2,"leadTime":2,"unitCost":1.75,"unitQty":"950"}
{"supplierName":"Meat Kings","paymentTerms":1.5,"leadTime":0.125,"unitCost":2,"unitQty":"650"}
Sales data
The sales data will be in a JSONL format describing the aggregated sales per hour describing the date/time, the qty sold in that hour, and the average discount in the hour, you can see a sample below:
{"date":"2023-10-29T09:00:00.000Z","qty":"51","price":2.0900000000000003}
{"date":"2023-10-29T10:00:00.000Z","qty":"105","price":1.5620000000000003}
{"date":"2023-10-29T11:00:00.000Z","qty":"140","price":1.9800000000000002}
Constant values
At times you will undertake solutions which use known constant and static values, as an example, assume that along with the files, you were provided with the regular retail price, which will be defined as a constant typescript value in the project:
const rrp = 2.20;
Later you will use this value as the price of the products in sales.
Define data sources
To use the input files, you can create file sources using a SourceBuilder
, by taking the following steps:
- add a
SourceBuilder
for each file to parse - make a
file
source and define the path
In the code add the above changes:
const procurement_file = new SourceBuilder("Procurement File").file({ path: 'data/procurement.jsonl' })
const sales_file = new SourceBuilder("Sales File").file({ path: 'data/sales.jsonl' })
const suppliers_file = new SourceBuilder("Suppliers File").file({ path: 'data/suppliers.jsonl' })
Define parsing of blob data sources
After defining data sources, you can parse the data into a tabular format using the PipelineBuilder
, by taking the following steps:
- add a
PipelineBuilder
for each file to parse - add the file source output to each
PipelineBuilder
- define the parsed type using
fromJsonLines
- define the fields to parse and their type (matching the input data)
- define the output kay as a value that is unique
In the code add the above changes:
const sales_data = new PipelineBuilder('Sales')
.from(sales_file.outputStream())
.fromJsonLines({
fields: {
// the date of the aggregate sales records
date: DateTimeType,
// the qty sold in the hour
qty: IntegerType,
// the price applied during that hour
price: FloatType,
},
// the sale date is unique, so can be used as the key
output_key: fields => Print(fields.date)
})
Repeat similar steps for supplier_data
and procurement_data
.
Define key date calculation
To perform simulation in a scenario, it is necessary to know when the simulation needs to being and end, you can use the sales records as a reference for date/time range. You can calculate the key dates for simulation using a FunctionBuilder
, with the following steps:
- add a
FunctionBuilder
- add an
input
of the sales data - add a function body, advantage
- calculate the last observable data with a
Reduce
, starting atDefaultValue
which is the unix epoch - starting from the last observed date, calculate the first sale date
- Return the first and last dates
- calculate the last observable data with a
In the code add the above changes:
const sales_dates = new FunctionBuilder("Sales Dates")
.input("sales", sales_data.outputStream())
.body(builder => builder
// when the last sale hour occurred (this is the current date time)
.let("last", vars => Reduce(
vars.sales,
(prev, sale) => Max(prev, GetField(sale, 'date')),
DefaultValue(DateTimeType))
)
// when the first sale hour occurred (historically)
.let("first", vars => Reduce(
vars.sales,
(prev, sale) => Min(prev, GetField(sale, 'date')),
vars.last
))
// return all the dates
.return({
last: vars => vars.last,
first: vars => vars.first,
})
)
Define resources
To perform simulation, you need to define the resources that you identified for the example business. Resources have types, and can be 'mapped' from data, or values which creates resources of the same type as the mapped data. You can define the resources using the ResourceBuilder
with the following steps:
- add a
ResourceBuilder
for theCash
,Liability
,Inventory
andPrice
which are mapped (created) from constant values usingmapFromValue
- for
Cash
,Liability
,Inventory
you can choose the actual value communicated by the business owner - for the price you can choose the "average" based on the bounds of price communicated with the business owner
- for
- add a
ResourceBuilder
for theSuppliers
, which are mapped from the values in supplier_data - add a
ResourceBuilder
for theReports
, which are hourly performance reports, starting with an empty collection
In the code add the above changes:
const cash = new ResourceBuilder("Cash").mapFromValue(500)
const liability = new ResourceBuilder("Liability").mapFromValue(0)
const inventory = new ResourceBuilder("Inventory").mapFromValue(2500n)
const price = new ResourceBuilder("Price").mapFromValue(rrp - (rrp - rrp * 0.7) / 2)
const suppliers = new ResourceBuilder("Suppliers").mapFromStream(supplier_data.outputStream())
// create a report resource to store the hourly reports
const reports = new ResourceBuilder("Reports").mapFromValue(
new Map(),
DictType(
StringType,
StructType({
date: DateTimeType,
cash: FloatType,
liability: FloatType,
inventory: IntegerType,
})
)
)
Note that mapping describes how resources are initially define in simulation.
mapFromStream
: will perform a data driven mapping which will define the initial value of a resource from the current value of a data stream.mapFromValue
: will automatically perform data driven mapping, by creating a value source for the resource and using the value to initialise the resource in simulation
When mapping a resource from a value, like a value source, if the value in type script doesn't define the type, it will need to be defined manually. In the above example, the Reports
resource is mapped from a new Map()
. Since the type key and value isn't expressed by an empty Map
, the type is manually defined.
The signature of mapFromValue
is as follows, where the first argument is the value, and the second is the type of the value:
mapFromValue(value: ValueTypeOf<EastType>, type: EastType)
Define processes
Once you have created processes, you can start to create the processes that will be used to simulate the business with the ProcessBuilder
.
Define sales process
The value network describes the relationships between sales, and resources, as follows:
You can define a sales process to reflect the diagram, with the following steps:
- add a
ProcessBuilder
for theSales
process - add the resources that are used in the process
- add the values that are used in the process (which need to exist in the data set)
- A
ProcessBuilder
has an implicitdate
value, which is the date/time of the process
- A
- add the logic to calculate the qty sold and amount of money in the hour
- add the logic to update the inventory and cash balance
In the code add the above changes:
// create the sales process that exchanges cash for inventory
const sales = new ProcessBuilder("Sales")
.resource(price)
.resource(inventory)
.resource(cash)
.value("qty", IntegerType)
.value("price", FloatType)
// can only sell if there is enough inventory, so update the qty
.assign("qty", (props, resources) => Min(resources["Inventory"], props.qty))
// get the total amount of revenue
.let("amount", props => Multiply(props.qty, props.price))
// update the inventory balance by the qty
.set("Inventory", (props, resources) => Subtract(resources["Inventory"], props.qty))
// update the cash balance by the amount
.set("Cash", (props, resources) => Add(resources.Cash, props.amount))
// the initial data comes from the historic sale data
.mapManyFromStream(sales_data.outputStream())
ProcessBuilder
vs FunctionBuilder
Defining a ProcessBuilder
is similar to a FunctionBuilder
, though it has a few differences:
- it can 'import' and use or change
resources
andprocesses
- it can have
values
defined, which could be though of as function arguments - a process always needs to have a
date
, which represents when the process was undertaken - it can be mapped from data or values
- it does NOT require definition of return values or a body
Other than the above, many of the methods available in a function can be used in a process, such as assign
, set
, let
, forDict
, etc.
ProcessBuilder
TypeSince a process has an implicit date
value, the will always be at least:
DictType(StringType, StructType({ date: DateTimeType }))
In the above example, the type of the Sales
process is
DictType(StringType, StructType({ date: DateTimeType, qty: IntegerType, price: FloatType }))
Define procurement process
The procurement process is more complex than sales, since it involves performing other processes at later dates. The value network describes the relationships between procurement, and resources, as follows:
You can define the procurement process to reflect the diagram by creating the different processes as per the value network.
Define receiving goods process
First, you can define the process to receive goods from a supplier, with the following steps:
- add a
ProcessBuilder
for theReceive Goods
process - add the inventory resource to reference and/or in the process
- add the
qty
value as the qty of inventory to receive- remember a
ProcessBuilder
has an implicitdate
value, which is the date/time of the process
- remember a
- add the logic to update the inventory by the qty
In the code add the above changes:
// receive stock from a supplier, place in inventory
const receive_goods = new ProcessBuilder("Receive Goods")
.resource(inventory)
.value("qty", IntegerType)
// update the inventory by the qty
.set("Inventory", (props, resources) => Add(resources.Inventory, props.qty))
Define supplier payment process
Next, you can define the process to pay a supplier, with the following steps:
- add a
ProcessBuilder
for thePay Supplier
process - add the cash, liability, and inventory resources to reference and/or set in the process
- add the
supplierName
,unitCost
, andqty
values as the supplier name, unit cost, and qty of inventory to pay for- remember a
ProcessBuilder
has an implicitdate
value, which is the date/time of the process
- remember a
- add the logic to calculate the total amount to pay the supplier
- add the logic to update the liability and cash by the amount
In the code add the above changes:
// pay the supplier for some ordered inventory, and clear the liability
const pay_supplier = new ProcessBuilder("Pay Supplier")
.resource(cash)
.resource(liability)
.resource(inventory)
.value("supplierName", StringType)
.value("unitCost", FloatType)
.value("qty", IntegerType)
// the total amount to be paid
.let("amount", props => Multiply(props.qty, props.unitCost))
// the debt has been cleared
.set("Liability", (props, resources) => Add(resources.Liability, props.amount))
// update the cash by the amount
.set("Cash", (props, resources) => Subtract(resources.Cash, props.amount))
Define procurement process
Lastly you can complete the procurement process, with the following steps:
- add a
ProcessBuilder
for theProcurement
process - add the cash, liability, inventory, and suppliers resources to reference and/or set in the process
- add the other procurement processes as steps in the process
- add the
supplierName
value as the supplier name- remember a
ProcessBuilder
has an implicitdate
value, which is the date/time of the process
- remember a
- add the logic to get the supplier since the
Supplier
resource is a collection - add the logic to calculate the total amount to pay the supplier
- add a conditional block to only order if there is enough cash available
- update the
Liability
by the amount execute
theReceive Goods
process, passing the calculated date, supplier name, and qtyexecute
thePay Supplier
process, passing the calculated date, supplier name, unit cost, and qty
- update the
- add the logic to map the initial data from the historic procurement data using
mapManyFromStream
In the code add the above changes:
// order some inventory, schedule supplier payment and receiving the inventory later
const procurement = new ProcessBuilder("Procurement")
.resource(cash)
.resource(suppliers)
.process(pay_supplier)
.process(receive_goods)
.resource(liability)
.value("supplierName", StringType)
// get the supplier
.let("supplier", (props, resources) => Get(resources.Suppliers, props.supplierName))
// calculate the total amount to pay the supplier
.let("amount", (props) => Multiply(GetField(props.supplier, "unitCost"), GetField(props.supplier, "unitQty")))
// only order if there is enough cash available
.if(
(props, resources) => GreaterEqual(resources.Cash, props.amount),
block => block
.set("Liability", (props, resources) => Subtract(resources.Liability, props.amount))
// schedule receiving the inventory
.execute("Receive Goods", props => Struct({
date: AddDuration(props.date, GetField(props.supplier, "leadTime"), "day"),
supplierName: props.supplierName,
qty: GetField(props.supplier, "unitQty")
}))
// schedule paying the supplier
.execute("Pay Supplier", props => Struct({
date: AddDuration(props.date, GetField(props.supplier, "paymentTerms"), "day"),
supplierName: props.supplierName,
unitCost: GetField(props.supplier, "unitCost"),
qty: GetField(props.supplier, "unitQty"),
})),
)
// the initial data comes from the historic procurement data
.mapManyFromStream(procurement_data.outputStream())
A helpful way to view process execution
, is that it is a way to schedule other processes to be executed at a later date. In the above example, the Procurement
process schedules the Receive Goods
and Pay Supplier
processes to be executed at a later date.
Define reporting
You have now defined the equivalent of the value network for the example business, and can now define a process to generate hourly reports. Creating a process for reporting allows us to replicate something that often happens in a business, which is observing and recording the values of important resources over time.
Use ProcessBuilder
to build a process that creates hourly reports for cash, liability, and inventory with the following steps:
- add a
ProcessBuilder
for theReporter
process - add the cash, liability, inventory, and reports resources to reference in the process
- add the logic to insert a report into the reports resource
- the key of the report is the date
- the value of the report is a struct containing the date, cash, liability, and inventory
- add the logic to create another report in an hour - by executing the
Reporter
process - add the logic to map
In the code add the above changes:
// create the hourly reports
const reporter = new ProcessBuilder("Reporter")
.resource(cash)
.resource(inventory)
.resource(reports)
.resource(liability)
// insert a report into the reports resource
.insert(
(_props, resources) => resources.Reports,
(props, _resources) => Print(props.date),
(props, resources) => Struct({
date: props.date,
cash: resources.Cash,
liability: resources.Liability,
inventory: resources.Inventory,
})
)
// create another report in an hour
.execute("Reporter", props => Struct({ date: AddDuration(props.date, 1, "hour") }))
// the first report should start at the first sale date (in the past)
.mapFromPipeline(builder => builder
.from(sales_dates.outputStreams().first)
.transform((date) => Struct({ date }))
)
Like a function in javascript, a process can call itself. In the above example, the Reporter
process calls itself to create another report in an hour. The ability for recursion results in a need for similar care and testing that would be applied in javascript programming, and because of this, it is recommended to define an end date as shown in the following section.
Elara does attempt to automatically detect and prevent infinite recursion, and cancel accidental infinite recursion.
Define descriptive scenario
Lastly to create a simulation, using the ScenarioBuilder
you can define a scenario to simulate the historic performance of the example business and produce the descriptive insights.
Use ScenarioBuilder
to build a scenario with the following steps:
- add a
ScenarioBuilder
for theDescriptive
scenario - add the resources and processes that are to be simulated in the scenario
- add the logic to end the simulation at the last sale date
In the code add the above changes:
// create a scenario to simulate historic business outcomes
const descriptive = new ScenarioBuilder("Descriptive")
.resource(cash)
.resource(inventory)
.resource(liability)
.resource(price)
.resource(suppliers)
.resource(reports)
.process(sales)
.process(receive_goods)
.process(pay_supplier)
.process(procurement)
.process(reporter)
// end the simulation at the last sale date
.endSimulation(sales_dates.outputStreams().last)
Like any other builder, the ScenarioBuilder creates a task for Elara to run, which produces data stream outputs. In the case of the ScenarioBuilder, various streams are available through convenience methods, such as:
- simulationJournalStream(): Return data stream containing a single journal detailing all the processes that occur during a simulation in chronological order.
- simulationResultStreams(): Return an object of data streams containing the final simulation results, keyed by the name of the resource.
For example, to access the hourly reports generated in the simulation, you can use the simulationResultStreams
method:
descriptive.simulationResultStreams().Reports
which is a tabular data stream with the following type, which could be used in a pipeline, function or visualisation, or even another resource, process!
DictType<StringType, StructType<{
date: DateTimeType;
cash: FloatType;
liability: FloatType;
inventory: IntegerType;
}>>
Define dashboard
Finally, to view the descriptive insights you can add a dashboard.
Define vega layouts
First, you can build visual layouts for the graphs of cash balance, inventory level, and liability over time using LayoutBuilder
.
To build the cash graph, use the LayoutBuilder
with the following steps:
- add a
LayoutBuilder
for theCash
graph - add a
vega
graph with an appropriate title "Cash Balance vs Time"- add a
view
to the graph- add a
fromStream
to the view to define the chart from theReports
- add a
line
to the view- add an
x
value to the line using thedate
field - add a
y
value to the line using thecash
field
- add an
- add a
- add a
In the code add the above changes:
const cash_graph = new LayoutBuilder("Cash")
.vega(
"Cash Balance vs Time",
builder => builder
.view(builder => builder
.fromStream(descriptive.simulationResultStreams().Reports)
// create a line chart of the cash balance vs time, with a different color for each scenario
.line({
x: builder => builder.value(fields => fields.date).title("Date"),
y: builder => builder.value(fields => fields.cash).title("Cash"),
})
)
)
Repeat similar steps for inventory
and liability
.
Define dashboard layout
Finally, you can construct a dashboard layout that integrates all the vega graphs and add key performance indicators.
To define the dashboard layout, use the LayoutBuilder
with the following steps:
- add a
LayoutBuilder
for theDashboard
layout - add a
tab
to the layout- add a
layout
to the tab for each graph
- add a
- add a
header
to the layout- add an
item
to the header for each key performance indicator- add a
fromStream
to the item to define the value from thesimulationResultStreams
- add a
value
to the item to define how the value is displayed
- add a
- add an
In the code add the above changes:
const dashboard = new LayoutBuilder("Dashboard")
.tab(builder => builder
.layout(cash_graph)
.layout(liability_graph)
.layout(inventory_graph)
)
.header(
builder => builder
.item(
"Price",
builder => builder
.fromStream(descriptive.simulationResultStreams().Price)
.value((value) => PrintTruncatedCurrency(value),)
)
.item(
"Profit",
builder => builder
.fromStream(descriptive.simulationResultStreams().Cash)
.value((value) => PrintTruncatedCurrency(value),)
)
.item(
"Inventory",
builder => builder
.fromStream(descriptive.simulationResultStreams().Inventory)
.value((value) => value)
)
.item(
"Liability",
builder => builder
.fromStream(descriptive.simulationResultStreams().Liability)
.value((value) => PrintTruncatedCurrency(value))
)
)
Export solution
After defining all these components, compose everything into a Template
to export as the entire solution. Use the export default
statement to make the template available for deployment:
export default Template(/* list all components here */)
Congratulations, you have now created an end-to-end solution that incorporates, ingestion, pre-processing, simulation, and reporting.
Example Solution
The final solution for this tutorial is available below:
Next Steps
In the next lesson, you will learn about machine learning functions, and later you will learn about how to use them to build predictive insights.