Skip to main content

Calendar profiles

A model without a profile runs at one average for ever. Real demand does not: a shop is busier at the weekend, a charging hub peaks morning and evening, a line runs three shifts on weekdays and one on Saturday.

A profile is a named, repeating, piecewise-constant factor over a cycle. Several different things can name one, and the factor means something different to each:

who names itkeythe factor is
a releaserate_profilea multiplier on its arrival rate
a stepscale_profilea multiplier on its process duration
a resource or an operator poolroster_profile⚠️ a count of how many are on shift
a store's lead timelead_time_profilea multiplier on how long an order takes

⚠️⚠️ The first two are reciprocals of each other, which is why they are separate keys rather than one profile: 1.6 on a rate means 60% more arrivals, so gaps get shorter, while 1.6 on a duration means takes 60% longer. A single key would be ambiguous on exactly the field where getting it backwards is easiest.

profiles:
- id: week
cycle_seconds: 604800.0
segments:
- { until_seconds: 432000.0, factor: 1.0 }
- { until_seconds: 604800.0, factor: 2.5 }

Five days at the base rate, then two days at two and a half times it. until_seconds is measured from the start of the cycle, so the document states boundaries rather than durations.

⭐⭐ What the flat average hides​

The point is not that the numbers differ a little. Below are two models of the same shop, with the same mean arrival rate, the same three tills and the same service time. One spreads its arrivals evenly; the other concentrates them into the weekend.

mean cycle timecompleted
the flat average307.29 s76,820
⚠️ the same demand, profiled23,633.75 s76,842

⭐ Seventy-seven times longer, for the same amount of work. Both runs complete the same number of shoppers — the difference is entirely when they arrive.

The arithmetic says why, and it needs no simulator:

arrival rateρ
on average0.015873 /s0.794 — comfortable
⚠️ at the weekend peak0.027778 /s1.389 — above 1

A stationary model of this shop reports a utilisation of 79% and a queue of a few minutes, and every number in it is correct about the average. ⚠️ The shop still collapses every Saturday, because for two days in seven it is asked for more than it can do, and the queue it builds takes the rest of the week to drain. Sizing the tills on the average answers a question nobody asked.

The two models​

The flat average, run with cwright run --replications 12:

# yaml-language-server: $schema=https://clockwright.dev/schema/model.json
name: corner-shop-flat
horizon_seconds: 4838400.0
warmup_seconds: 604800.0
resources:
- { id: till, servers: 3, queue_capacity: 65536 }
products:
- id: shopper
steps:
- resource: till
process_seconds: { kind: exponential, mean: 150.0 }
releases:
- product: shopper
interarrival_seconds: { kind: exponential, mean: 63.0 }

The same demand, profiled, run the same way:

# yaml-language-server: $schema=https://clockwright.dev/schema/model.json
name: corner-shop
horizon_seconds: 4838400.0
warmup_seconds: 604800.0
resources:
- { id: till, servers: 3, queue_capacity: 65536 }
products:
- id: shopper
steps:
- resource: till
process_seconds: { kind: exponential, mean: 150.0 }
releases:
- product: shopper
interarrival_seconds: { kind: exponential, mean: 90.0 }
rate_profile: week
profiles:
- id: week
cycle_seconds: 604800.0
segments:
- { until_seconds: 432000.0, factor: 1.0 }
- { until_seconds: 604800.0, factor: 2.5 }

The flat model's interarrival of 63 s is not a coincidence: it is 90 s divided by the profile's time-average factor of (5×1.0 + 2×2.5) / 7 = 1.428571, so the two models really do receive the same number of shoppers.

The fields​

⭐ The keys that point at a profile are documented where they live — on a release, a step, a resource and an operator pool. One profile may be named by any number of them, in any sense: a weekly cycle that shuts a line at the weekend and stops releasing into it is one profiles entry, named twice.

profile​

keytyperequiredwhat it means
idstringyesThe name a release, a step, a resource or a pool points at.
cycle_secondsnumberyesHow long before the pattern repeats. ⚠️ The measured window must be a whole number of cycles; see the warning below.
segmentsarray of segmentyesThe cycle, in order. Must cover it exactly: the segments run from zero and the last one ends at cycle_seconds.

segment​

keytyperequiredwhat it means
until_secondsnumberyesWhere this segment ends, measured from the start of the cycle rather than from the previous segment — so a document states boundaries and cannot drift.
factornumberyesThe value in force during it: a multiplier for a rate_profile or a scale_profile, and ⚠️ a headcount for a roster_profile, where a fraction is refused.

⚠️ What is refused, and why​

A profile that does not describe a cycle is refused rather than guessed at:

  • segments out of order, or two segments ending at the same instant — a zero-width segment contributes nothing and reads as if it did
  • a last segment that does not reach cycle_seconds — the remainder would run at an unstated factor of 1.0, which reads as a deliberate quiet period rather than an unfinished document
  • a negative or non-finite factor, or a cycle that is zero or negative
A factor of zero on a rate deletes demand; it does not defer it

A rate_profile scales an arrival rate, so a segment at factor 0 is a period in which nobody arrives — and the customers who would have arrived are gone, not queued for later.

Measured: deterministic arrivals every 100 s over 700,000 s release 7,001 lots; the same stream with a factor-0 segment over two sevenths of the cycle releases 5,001 — exactly five sevenths. Nothing in the results document says the other 2,000 were never created.

⭐ That is right for "the market closes" and wrong for "the shop closes and everybody comes on Monday". If you mean the second, the demand has to keep arriving and the server has to be shut — see rosters below.

  • every factor zero — that silences the stream for the whole run, which is a model with no releases rather than a calendar
  • a rate_profile naming a profile that does not exist — a name that resolves to nothing must not quietly mean "factor 1.0 for ever". The same holds for every other key in the table above
  • a profile that nothing names — it is hashed into the model's identity and changes no answer, and the refusal lists every key that could have named it
  • a lead_time_profile on a store with no lead_time_seconds — there is no duration for it to bend, so it is the same silent nothing
The measured window must be a whole number of cycles

warmup_seconds decides where the measurement starts and horizon_seconds decides how much of the profile it covers. A window of two and a half weeks over a weekly profile sees Monday and Tuesday three times and the weekend twice, and every statistic is biased by the phase it happened to span.

⚠️ Nothing in the output says so — the run completes, the interval looks tight, and the number is wrong. Both are therefore refused when a release names a profile, and the refusal names the two nearest legal values.

Rosters​

A roster_profile is the third sense, and the one that is not a multiplier. Its factor is a count: how many of a resource's servers, or a pool's count, are staffed during that segment.

resources:
- { id: line, servers: 2, roster_profile: week }
operators:
- { id: crew, count: 3, roster_profile: week }
profiles:
- id: week
cycle_seconds: 604800.0
segments:
- { until_seconds: 432000.0, factor: 2.0 }
- { until_seconds: 604800.0, factor: 0.0 }

⚠️⚠️ A count, so a fraction is refused rather than rounded. 2.4 servers is not a plant and 2.4 people is not a shift, and a factor above servers or count is refused too — the refusal names the profile, the segment and the two numbers.

⭐ A closed shift is not downtime​

A rostered-off server reports NonScheduled, which sits outside the availability denominator rather than counting against it. That is SEMI E10's distinction and it is worth the column: modelling a weekend closure as maintenance instead reports a perfect machine as 71.43% available where a roster reports 100.00%, which would send a planner shopping for maintenance when what they need is headcount.

⚠️ A resource may not declare a roster_profile together with a downtime that declares either of its mechanisms, for now — a bare on_collision is accepted, and is inert on its own. A station that is both rostered off and broken raises a question the engine does not yet answer: which state it returns to when the shift starts, and whether the failure clock ran while nobody was there. Guessing would put hours into PM%, the one column measured against a published reference.

⭐⭐ A shift end finishes the task in hand — on both sides​

Both halves wait, and that symmetry is the point: a server is withdrawn only while it is holding nothing, and a person is sent off only once somebody is free. ⭐ So the distinction is roster versus downtime, not people versus machines. A breakdown seizes the tool mid-operation and banks the remaining work; a shift end is planned hours ahead, so the lot in hand is finished. A server that is mid-changeover, waiting for an operator or already down is likewise not withdrawn.

⚠️⚠️ The consequence, which the segment table does not show you. Realised NonScheduled is strictly below the declared off-share whenever work is in progress at a boundary. Measured on one station, a 100 s cycle with 50 s off, and one lot that starts at t=30 needing 40 s:

the roster's declared off-share50.000%
⭐ realised NonScheduled43.333% — it stays on until t=70, so 130 s of 300
the lot's own service time40.0 s, unbroken — identical to the unrostered run

⭐ Banking the remainder instead would give a cycle of 90 s and the full 50.000%. That is what this page claimed until 2026-09-24; the engine's own test suite refuted it, and the claim was corrected rather than the engine.

⭐ Why this is not a break​

An operator break is deliberately staggered across the pool — first_at_seconds exists so a pool never goes to lunch together and stop the fab twice a day. ⚠️ And the stagger is over first_at_seconds, not over every_seconds — which is the parameter that decides whether the breaks overlap, so a measurement of this is worthless without it. Three people, two days off every seven:

first_at_secondsstaggerbreak_concurrency
432,000 (5 d)144,000 s < the 172,800 s break, so adjacent pairs overlap[0.238, 0.667, 0.095, 0.000]
604,800 (7 d)201,600 s > the break, so the three are disjoint[0.143, 0.857, 0.000, 0.000]

⭐ Never even two off together in the second, and never all three in either — and both are exact: the first row's P(2 off) is 2 × 28,800 / 604,800, the second's P(0 off) is 1 − 3 × 2/7. A roster wants precisely what this mechanism prevents — everybody off together, phase-locked to the cycle — so it is a separate key rather than a break with a long period.

A duration that holds no resource​

The three senses above all attach to something the engine can suspend: a stream it can stop releasing from, a lot sitting on a server, a server it can take off shift. A store's lead_time_seconds attaches to nothing — an order in transit occupies no tool and no person — so a closure could not reach it at all. lead_time_profile is that key.

consumables:
- id: part
capacity: 1.0
initial: 1.0
reorder_at: 1.0
reorder_quantity: 1.0
lead_time_seconds: { kind: constant, value: 200.0 }
lead_time_profile: week

⚠️ The factor scales the duration, the way scale_profile does and not the way rate_profile does. 2.0 is a supplier taking twice as long; 0.0 is a segment in which the order makes no progress at all. And it is integrated across every boundary it spans rather than fixed at the factor in force when the order was placed — the same integrator a step's scale_profile uses, so the two senses cannot drift apart.

⭐ Measured on one line rostered off for 90% of its cycle and a store whose 200 s lead time spans the closure, with two lots released at t=10 and t=50. The construction is deterministic end to end, so there is no sampling error in any column:

no calendarthe line rostered off⭐ and the lead time on the same calendar
the lot's mean cycle time81.0 s476.0 s981.0 s
line time in NonScheduled0.0%90.0%90.0%
⭐ the store's mean wait for material80.0 s80.0 s980.0 s

⭐⭐ Read the last row across. Rostering the line does a great deal to the lot and nothing at all to the lead time — the order is placed at t=10 and lands at t=210 whether the shop is open or shut. With the third column the order accrues 90 of its 200 seconds before the shop closes at t=100, a whole 100-second window goes by in the next cycle, and the last 10 accrue in the one after: it lands at t=2010, and the two readings are a factor of 12.25 apart.

A reopened window is not unbounded

⚠️⚠️ This page said 530 s until 2026-09-24, from a hand-written prediction that the remaining 110 seconds would all accrue in the next open window. That window is 100 seconds wide, so a 200-second lead needs three of them. The number was wrong by a factor of 1.85 — and it was wrong in three separate places, because it had been copied rather than derived. ⭐ It is now measured by a test that computes the landing instant by walking the calendar, so it cannot drift again.

⚠️ What still has no calendar​

Four other durations hold no resource and have no such key yet — a step's patience_seconds and transport_seconds, a resource's mttr_seconds, and a maintenance policy's duration_seconds. Each is the same shape by construction rather than by measurement.

⚠️⚠️ And a deadline is not one of them. A queue timer and a due date are absolute ticks, and they legitimately burn closed hours — a lot does not stop being late because the shop shut. "Two business days" is a deadline rather than a duration, so no profile turns it into one, and a due date additionally drives CriticalRatio dispatch.

⭐ It is deterministic, and that matters more than it sounds​

A profile consumes no randomness. It is a pure function of simulation time, which has two consequences worth stating:

  • a profile of factor 1.0 everywhere is bit-identical to no profile at all — same trace hash, same arrivals, same per-entity cycle times
  • common random numbers survive it, so compare still pairs. Holding a profile fixed while changing capacity, pairing reduces the variance of the difference about 4× on the development laptop (AMD Ryzen 7 7840U)

⚠️ Comparing two different profiles is a different matter, and pairing does not help there: changing a profile changes when arrivals happen, so the two arms share no trajectory. That is inherent to any time-varying arrival process rather than a limitation here — measure the arms separately and expect no variance reduction.

What this does not do yet​

⭐ A scale_profile factor of zero really does pause work accrual, so do not read the sections below as saying otherwise. Measured on a 100 s job starting at t=50 against a cycle of [1.0 until 100, 0.0 until 200]: it accrues 50 s of work by t=100, nothing from t=100 to t=200, and finishes at t=250. That is "100 s of open time, however much calendar it spans".

⚠️⚠️ What is not expressible is a countdown that holds no resource. The job above occupies its tool for the whole 200 s, pause included, so it is not a settlement clock: "T+2 business days" belongs to no machine, and there is nothing here to attach it to. ⚠️ And a factor of zero on a rate_profile deletes the demand rather than deferring it, which the warning above measures.

⚠️⚠️ And a roster is not a substitute, because it staffs a resource rather than closing a calendar. Only a resource and an operator pool can carry one — there is no model-level roster — so a closure reaches exactly what is waiting on that resource and nothing else. What it cannot reach is a duration that holds no resource: see the section above, which is what lead_time_profile exists for, and the four durations that still have no such key.