2.0.0
⚠️ Breaking changes
- Event logs containing more than one simulation run are now rejected by all four animation functions instead of being silently blended into a single animation. If you were passing an unfiltered multi-run log, you were not getting the animation you thought you were; filter to one replication first.
- Exit steps are written to your
event_type_col_namecolumn instead of a hardcodedevent_typecolumn — output ofreshape_for_animationschanges if you pass a custom event type column name. TrialLogger.get_event_duration_stat(what="summary")reported the total entity count underunserved_count. It now reports the number unserved, so that figure andunserved_count_mean_per_runwill change.TrialLoggerstatistics now include runs added viaadd_logafter construction, which were previously omitted from every calculation.TrialLogger.plot_queue_sizeplotted queue lengths that were wrong in three ways: capped at 61, missing every snapshot where a queue was empty, and a mean taken over only the runs that had somebody waiting. Any queue length chart you have previously reported will change.TrialLogger.get_event_duration_stat(what="summary")computed its per-run denominator only from runs where the event pair occurred at all. A run with neither event was silently excluded, soserved_count_mean_per_runandunserved_count_mean_per_runwere inflated whenever any run had zero of both events; both now divide by the true number of runs in the trial.TrialLogger.get_resource_utilisation(),.plot_resource_utilisation()and.plot_resource_utilisation_over_time()now defaultresource_col_nametoNone(auto-detect) instead of the literal"resource_id". If your trial has aunique_resource_idcolumn on some resource-use rows but not others, a call that used to succeed under the old default now raisesValueError— passresource_col_name="resource_id"explicitly to keep the old behaviour, or fix the partial logging.animate_activity_log/generate_animationnow raiseValueErrorifcustom_hover_datais passed without a customhover_text_entity. The built-in default template indexes six fixed columns, so combining it withcustom_hover_datapreviously rendered garbled hover text; callers who never passedcustom_hover_data, or who already paired it with their own template, are unaffected.- New entities now glide into the animation from the
event_position_df"arrival"anchor (spawn_in_from_arrival, defaultTrue) instead of flying in from the plot’s top-left corner. Any animation whose layout gives"arrival"a position now looks different for those entities; a layout that never positioned"arrival"is unchanged. Passspawn_in_from_arrival=Falsetoanimate_activity_log/generate_animation_dffor the old fly-in. VidigiStore/VidigiPriorityStore.capacitynow returns the pool size for a pool built withnum_resources=/populate()(wasfloat("inf")), so it mirrorssimpy.Resource.capacity— equal tonum_resources. A store built with an explicitcapacity=, and a bareVidigiStore(env), are unchanged. Closes #87.- Returning more units to such a pool than it holds now raises
ValueErrorat the call site (a unit returned twice, or one from another pool, previously grew the pool silently and only surfaced later as the.countRuntimeError). Pass the newstrict_capacity=Falseconstructor argument to allow the pool to grow via a rawput(). - The default resource dot (no
custom_resource_iconand no per-eventEventPosition.resource_iconglyph override) now honoursresource_icon_sizeinstead of a hardcodedsize=15marker. Sinceresource_icon_sizedefaults to24, any animation that never set it will now show a larger default dot. Closes #120.
New features
- New
stage_label_offsetargument onanimate_activity_logandgenerate_animation, controlling the gap between an auto-generated stage label (display_stage_labels=True) and the front of its queue/resources - previously a hardcoded10data units, which could look squashed against larger icon sizes (closes #122)- Default
10matches the previous hardcoded value exactly, so existing animations are unaffected - The auto-expanding figure margin (added to keep long labels from being clipped) accounts for this offset too, so increasing it doesn’t reintroduce clipping
- Default
- New
ArrivalPosition/ExitPositionhelpers invidigi.utils-EventPositionsubclasses witheventpre-set to the exact string vidigi matches on ("arrival"/"depart"), so building anevent_position_dfno longer means looking those two up (closes #192)ArrivalPosition(x=50, y=450)is exactlyEventPosition(event="arrival", x=50, y=450, label="Arrival")-model_dump()and thecreate_event_position_dfDataFrame are byte-identical, so nothing downstream changeslabeldefaults to"Arrival"/"Exit"(still overridable); every otherEventPositionfield is inherited unchanged- Passing a conflicting
event=raisesValidationErrornaming the fixed value and pointing toEventPositionfor a custom event name - Also exposes
vidigi.utils.ARRIVAL/DEPART/ARRIVAL_DEPARTUREstring constants for hand-built event logs and directEventLogger.log_eventcalls; existing inline string literals are untouched
- New
queue_directionargument onanimate_activity_log,generate_animationandgenerate_animation_df, plus an optional per-eventdirectioncolumn onEventPosition/event_position_df, for building a queue left-to-right instead of the default right-to-left- Many entity emojis face a direction that reads better with the front of the queue at the bottom-left rather than the bottom-right;
queue_direction="right"puts it there, and the queue (and its wrapped rows) mirror accordingly - Per-event
direction(EventPosition(..., direction="right"), or adirectioncolumn on a hand-built / CSVevent_position_df) overrides the animation-wide setting; anevent_position_dfwith nodirectioncolumn at all is unaffected - The default
"left"is a verified no-op -generate_animation_dfoutput is byte-identical to omitting the argument - Resource-use icon placement and the resource-availability dots follow the same setting, so an entity in service lines up with the side it queued on; stage labels move to the opposite side of a right-building queue, and the figure margin grows on whichever side now overflows
- Many entity emojis face a direction that reads better with the front of the queue at the bottom-left rather than the bottom-right;
- New
flip_entity_iconsargument onanimate_activity_logandgenerate_animation, plus an optional per-eventflip_iconscolumn onEventPosition/event_position_df, for mirroring entity icons (and acustom_resource_icon) horizontally - independently ofqueue_direction, so a layout is no longer constrained to whichever way an icon happens to facequeue_directiononly ever mirrored the layout; this mirrors the glyph itself, achieved by prefixing a zero-width marker onto flipped icons’ text and matching a CSS rule against it, since Plotly’s scattertexthas no rotation/flip property of its own. Seevidigi.utils.entity_icon_flip_css()/inject_icon_flip_css()- The default
Falseis a no-op: with no icon ever flipped, no marker is added and no CSS is injected - Per-event
flip_icons(EventPosition(..., flip_icons=True), or aflip_iconscolumn on a hand-built / CSVevent_position_df) overrides the animation-wide setting - The CSS is injected automatically (via IPython or Streamlit) whenever any icon actually resolves to flipped, so notebooks and Streamlit apps need nothing extra; embedding a figure another way (
fig.write_html(), a hand-built page) needsentity_icon_flip_css()added explicitly - see the new example. Does not affect a static export viafig.write_image(), which renders in its own page - The
+ N more/ ASCII-gauge overflow icon is always exempt, even when the whole animation is flipped - mirrored text is unreadable, whatever entity icon it happens to be attached to custom_resource_icon’s trace now carries one text entry per resource unit rather than a single string broadcast across all of them, so each can be flipped independently - the rendered animation is identical when nothing is flipped, but code inspectingfig.data[-1].textdirectly will now see a list rather than a bare string
- New
entity_icon_font/entity_icon_font_weightarguments onanimate_activity_logandgenerate_animation, for rendering entity icons in an icon font instead of emoji -custom_entity_icon_listthen supplies that font’s codepoints (or, for"material-symbols", ligature names like"directions_walk") instead of emoji- Emoji cap the available icon vocabulary and are colour fonts that ignore
textfont.colorentirely; an icon font opens up thousands of glyphs (Font Awesome, Bootstrap Icons, Material Symbols and any other CSS font-family are all accepted) and, being monochrome, is what makes the newentity_colour_by(below) visible - Ships three presets -
"font-awesome","bootstrap-icons","material-symbols"- viavidigi.utils.ICON_FONT_PRESETS; the CSS is injected automatically (via IPython or Streamlit) exactly likeflip_entity_icons, withvidigi.utils.entity_icon_font_css()/inject_icon_font_css()for embedding a figure another way. No font is bundled with vidigi - presets load from a CDN, so this needs network access at view time. Does not affect a static export viafig.write_image() - Needs plotly >= 5.23.0: an icon-font preset always resolves a numeric font weight (Font Awesome Solid is 900), and numeric
textfont.weighton scatter traces arrived in plotly.js 2.33.0, first bundled by plotly.py 5.23.0. Older plotly raisesValueError: Invalid value ... for the 'weight' property.resource_icon_fontis the same. Emoji animations are unaffected - vidigi’splotly>=5.12.0floor is unchanged - Two confirmed Plotly bugs, worked around rather than merely documented: a
textfont.familyvalue containing a standalone number - exactly the shape of “Font Awesome 6 Free”, the vendor’s own name - is silently dropped with no error (the built-in presets are pre-aliased under a digit-free name to route around this; a custom font name shaped the same way raises a clearValueErrorinstead of failing invisibly); and a browser’s automatic “does this page need this webfont” detection does not reliably notice Plotly’s SVG<text>icons, so the injected CSS also includes a small hidden element that reliably forces the font to load - The default
Noneis a verified no-op; the+ N more/ ASCII-gauge overflow icon is always left on the default font, whatever this is set to - a substituted glyph in place of the ASCII art would be worse than plain text
- Emoji cap the available icon vocabulary and are colour fonts that ignore
- New
entity_colour_by/entity_colour_map/show_entity_legendarguments onanimate_activity_logandgenerate_animation, for colouring entity icons by a column already on the event log (priority, pathway, acuity, …), with an optional legend- Only visible together with
entity_icon_font, since emoji ignore colour entirely;entity_colour_mapmaps specific values to specific colours, falling back to Plotly’s default qualitative palette for anything uncovered - The default
Noneis a verified no-op. Overflow rows always keepoverflow_text_colorand are never added to the legend, whatever category they would otherwise fall into - Implemented as one Plotly Express trace per colour category rather than a true per-point channel, since Plotly Express has none for
textfont.coloron an animated figure - works around a third confirmed Plotly bug: Express only creates a trace for a category actually present in a given frame, so a category with zero entities at some point in the animation (nobody of a given priority has arrived yet, say) would otherwise vanish from that frame - or from the whole animation, if that happened to be true of the first frame - rather than reappearing correctly once it does have entities - The empty placeholder trace this fills a missing category in with surfaced a fourth confirmed Plotly bug, this one specific to browsers rather than the frame data itself: a placeholder’s trace-level
opacity(harmlessly0, alongside already-nullx/y, for the empty frame that creates it) is never reset back to1by a later frame that does have real content, since Plotly’s frame animation only patches attributes a frame’s own trace data explicitly sets, and a real trace never sets one. Left unfixed, a colour category (orentity_icon_fontalone, via its own reserved"_entity"bucket) that happened to be empty in whichever frame first needed a placeholder would then render invisible for the rest of the animation, however many entities it later had - confirmed by inspecting the live DOM in a real browser (a trace<g>element stuck atopacity: 0), not just by reading frame data back in Python. Fixed by dropping that trace-levelopacityfrom the placeholder - the null coordinates already draw nothing on their own
- Only visible together with
- New
resource_iconfield onEventPosition/event_position_dffor a custom icon per resource, set per event and overridingcustom_resource_iconfor that stage, so resource stages can each look different - closes a long-standing TODO- The value is a text glyph (an emoji or short string, drawn as scatter text like
custom_resource_iconand mirrored byflip_entity_icons/ a per-eventflip_icons) or an image (a URL, local path, ordata:URI, recognised by an image file extension or URL scheme), drawn vialayout.imagesat the resource’s actual position. Resource icons are static across frames, so an image adds no per-frame cost - An image resource icon cannot be mirrored by
flip_entity_icons- Plotly has no per-image transform, unlike text - so supply it pre-mirrored if needed - New
resource_image_sizeargument sizes an imageresource_icon, defaulting toresource_icon_size- kept independent ofgap_between_resources, matching a text resource icon, so widening the spacing between resources doesn’t also inflate the image
- The value is a text glyph (an emoji or short string, drawn as scatter text like
- New
resource_icon_font/resource_icon_font_weightarguments onanimate_activity_logandgenerate_animation, for rendering glyph resource icons (custom_resource_icon, and any per-eventresource_iconthat is a text glyph) in an icon font instead of emoji- Deliberately separate from
entity_icon_font: entities and resources can each be in their own icon font, or one in a font while the other stays on emoji.entity_icon_fontnever re-fonted resource glyphs despite its docstring saying so - that claim is corrected and this argument is how you actually do it - Same accepted values as
entity_icon_font(avidigi.utils.ICON_FONT_PRESETSname or a raw CSS family), the same_resolve_icon_fontdigit-in-name guard, and the same automatic CSS injection. The codepoint or ligature goes straight intocustom_resource_icon/resource_icon- there is no list argument likecustom_entity_icon_list - Animation-wide, like
entity_icon_font- every glyph resource stage shares it (the resource glyphs are a single trace), so there is no per-stage font; an imageresource_iconis unaffected - The default
Noneis a verified no-op - glyph resource icons stay on the page default font
- Deliberately separate from
- New
entity_resource_offset_yargument onanimate_activity_logandgenerate_animation, for the vertical gap between a resource icon and the entity using it- The resource icon has always been drawn a fixed 10 data units below the entity; this exposes that offset so an entity can be lifted clear of a large
resource_image_size, or sat down onto its icon - Applies to all three resource-icon forms alike - the default dot, a glyph
custom_resource_icon/resource_icon, and an imageresource_icon- and the auto-layout bottom margin follows it - The default
-10reproduces the historic position exactly
- The resource icon has always been drawn a fixed 10 data units below the entity; this exposes that offset so an entity can be lifted clear of a large
- New
entity_annotation_by/entity_annotation_size/entity_annotation_color/entity_annotation_offset_yarguments onanimate_activity_logandgenerate_animation, drawing a column’s value as a second line of text offset below each entity’s icon (a running length-of-stay figure, a delayed-discharge flag, …)- Routes around a genuine Plotly/SVG ceiling rather than a vidigi gap: a single SVG
<text>node gets exactly onefont-familyand one transform, so text appended directly ontoicon/icon_display(the existing, cheaper way to annotate an icon, and still the recommended default) inherits whateverflip_entity_icons/entity_icon_fontdid to the icon glyph sharing its text node - mirrored digits, a broken ligature.entity_annotation_bydraws the annotation as a structurally separate scatter trace instead, built from its ownpx.scattercall over the same underlying rows so it inherits the existing per-frame/placeholder guarantees, and is never touched by either mechanism - The default
Noneis a verified no-op - no second trace is built at all. Express backend only, matchingentity_colour_by/entity_icon_font - Costs roughly double the per-frame point/text payload of appending onto
icondirectly, since it is a second, fully-animated trace - reach for it specifically when combining annotated icons withflip_entity_icons/entity_icon_font, not as a default replacement for appending
- Routes around a genuine Plotly/SVG ceiling rather than a vidigi gap: a single SVG
- New
step_snapshot_max_overridesargument onanimate_activity_log,reshape_for_animationsandgenerate_animation_df- a{event: cap}dict setting a per-eventstep_snapshot_max, so one long bottleneck queue can be shown in full ({"waiting_for_bed": 250}) while every other step stays capped at the scalarstep_snapshot_maxstep_snapshot_maxis unchanged and still the fallback for any event not named in the dict; the dict only overrides it per event- The default
Noneis a verified no-op - output is byte-identical to omitting the argument, through bothreshape_for_animationsandgenerate_animation_df - A dict key matching no event in the log raises a
UserWarning, so a misspelt event name is not silently ignored; a float cap rounds with the same warning a scalarstep_snapshot_maxalready gives - The
+ n moreoverflow label, thestep_snapshot_limit_gauges“+ n more” vs raw-count choice, and thewrap_queues_atmultiple check all follow the per-event cap
- New
step_snapshot_reveal_pop_inargument onanimate_activity_logandgenerate_animation_df, for stopping an entity hidden bystep_snapshot_maxfrom visibly flying in from the top-left of the plot the instant it becomes individually visible again - closes #143- Plotly draws entities as scatter
text, not markers, and a browser only fades a marker in on entry - a freshtextnode has no prior position, so Plotly’s frame transition interpolates it in from the pixel origin (the plot’s top-left corner). Confirmed by reading the bundledplotly.jsscatter source and reproducing the fly-in against a real browser DOM (Chromium via Playwright), not just by eyeballing the animation - Fixed by inserting one invisible phantom row - a zero-width space, at the entity’s destination coordinates - one snapshot before the reveal, via a new
hidden_run_beforecolumn onreshape_for_animations’s output (the number of consecutive prior snapshots an entity was present but capped out of view). The point then already exists, just invisibly, when the real icon appears, so there is nothing left for Plotly to animate as movement - only a content swap, which Plotly applies immediately rather than through the position transition. A single lead frame is enough regardless offrame_duration/frame_transition_duration- also verified against a real browser DOM, including at a 7.5x transition-to-frame-duration ratio - because the invisible phantom’s own entry can itself fly in unnoticed (a zero-width glyph draws no pixels wherever it is) - A genuine new arrival is unaffected (
hidden_run_beforeis0), so arrivals still visibly fly in - usually the clearer cue for “joining the system”, and what the issue asked to keep. The+ N moreoverflow row is also unaffected - it already had its own stable-id fix for this same class of problem (1.1.0, below) - and an entity that plays that overflow-row role before becoming individually visible is correctly treated as a reveal too, since its own id was never actually rendered while relabelled to the overflow row’s stable synthetic id - Costs exactly one extra row per reveal, not per entity hidden or per snapshot spent hidden. Hover text is deliberately left unblanked on a phantom row (invisible and zero-width, so hovering one precisely is unlikely, and it would only show accurate, one-snapshot-early data); an
entity_annotation_bylabel, unlike hover always visibly rendered, is blanked - The default
Falseis a verified no-op -generate_animation_dfoutput is byte-identical to omitting the argument. Planned to change toTrueat the next major version (3.0), since popping in is closer to correct than flying in for a reveal - Express backend only - the experimental
gobackend does not support this
- Plotly draws entities as scatter
- BREAKING: new
spawn_in_from_arrivalargument onanimate_activity_logandgenerate_animation_df, defaultTrue, for making a genuinely new entity glide into the animation from theevent_position_df"arrival"anchor instead of flying in from the plot’s top-left corner - the arrival-side mirror of how the syntheticdepartstep makes an exit land at a chosen anchor - closes #199- This is how vidigi should have behaved from the start, so it ships on by default rather than through a deprecation cycle. Callers on the defaults: any animation whose layout gives
"arrival"a position now has its new entities slide in from there rather than the corner; a layout that never positioned"arrival"is byte-for-byte unchanged. Passspawn_in_from_arrival=Falsefor the old fly-in - The spawn point is the existing
EventPosition(event="arrival", ...)anchor (the same row adepartanchor mirrors on the exit side) - no new API surface - Works by inserting, for each new entity, a visible row at the arrival anchor one snapshot before its first real position (so Plotly animates it moving from there) plus one invisible phantom row the snapshot before that (so the spawn row itself has nothing to fly in from - the same trick as
step_snapshot_reveal_pop_in). Both land on existing snapshot slots, so no frames are added; costs two extra rows per affected entity - Only affects entities that arrive at least two snapshots after the animation window opens - an entity already present when it opens has no earlier slot to spawn from and keeps the top-left fly-in. An entity that arrives straight into an over-cap queue (drawn as part of the
+ N moreoverflow row, not individually) is likewise unaffected until it emerges, at which point it is a reveal handled bystep_snapshot_reveal_pop_in - Independent of
step_snapshot_reveal_pop_in- that targets entities re-emerging from behind a+ N morelabel, this targets genuine new arrivals; both are on-by-default-worthy but only this one bypasses the deprecation cycle, since a reveal flying in is a rarer edge case - The phantom rows both features insert now carry
vidigi.utils.PHANTOM_ICON(a zero-width no-break space) rather than a zero-width space, so their placeholder glyph can no longer collide withICON_FLIP_MARKERin the icon-flip CSS selector - Express backend only - the experimental
gobackend always behaves asspawn_in_from_arrival=False
- This is how vidigi should have behaved from the start, so it ships on by default rather than through a deprecation cycle. Callers on the defaults: any animation whose layout gives
generate_animation_dfnow warns automatically when an event is genuinely rendered - it is some entity’s most-recently-logged step at a rendered snapshot - but has no matching row inevent_position_df, a second, unrelated way to get the same “flies in from the top-left” symptom as thestep_snapshot_reveal_pop_inbug above, found while writing it up- Checks the merged, per-snapshot frame rather than the raw event log, so it only fires on an event actually picked to represent some entity’s state and left with nothing to show for it - an event that is always simultaneous with (and so superseded by) its successor, such as
arrivalor aresource_use_endstep, is never selected for rendering and so never triggers this, however common that pattern is - Reuses the merge
generate_animation_dfalready builds, so it costs nothing extra to compute. Not breaking - animation output is unchanged, this only adds a warning to a case that was already silently wrong
- Checks the merged, per-snapshot frame rather than the raw event log, so it only fires on an event actually picked to represent some entity’s state and left with nothing to show for it - an event that is always simultaneous with (and so superseded by) its successor, such as
- New example notebook
examples/feat_custom_icons, walking through all five features together, including a two-stage model whose nurse and bed stages each carry their ownresource_iconrendered inresource_icon_font - New
warm_upargument onreshape_for_animationsandanimate_activity_log, for discarding a warm-up period without damaging the animation- Discarding warm-up is routine, and the obvious way to do it to an event log —
event_log[event_log["time"] >= warm_up]— quietly breaks the result. Presence at each snapshot is worked out from arrival and departure rows, so truncating the log removes thearrivalrow of everyone who was already in the system, and those entities then appear in no frame at all. The entities lost are precisely the ones a steady-state animation exists to show: on a log with five entities queuing since before the boundary and two arriving after it, the queue was drawn holding two warm_uptrims the animation window instead of the log. Pass the whole event log and setwarm_upto the end of your warm-up period; by default the snapshot grid is anchored on it, so the first frame lands exactly on the boundary — seesnapshot_alignmentbelow to keep the original grid insteadwarm_upandlimit_durationbound the window between them.limit_durationkeeps its existing meaning, so addingwarm_upto an existing call does not move the end of the animation- The default of
0is a verified no-op — output is identical to omitting the argument - Not to be confused with
animate_activity_log’s existingstart_time, which is a time of day used only for labelling frames as clock times - Also faster than filtering afterwards, since the discarded frames are never built at all — around 4.6x on a run that is 80% warm-up
- Discarding warm-up is routine, and the obvious way to do it to an event log —
- New
snapshot_alignmentargument, controlling where the snapshot grid counts from when awarm_upis set"warm_up"(the default) puts the first frame exactly on the boundary, so the animation opens on the state of the system as the warm-up ends"run_start"keeps the grid running from time 0 and drops the early frames, so frame times stay the same ones you would get with no warm-up — useful whenwarm_upis not a multiple ofevery_x_time_unitsand you would rather keep round numbers. This matches the longstanding workaround of filtering the reshaped frame onsnapshot_time, except that a snapshot falling exactly onwarm_upis kept rather than dropped- The two are identical whenever
warm_upis a multiple ofevery_x_time_units, and irrelevant when there is no warm-up - Alignment moves the frame times only — never which entities appear in them
- New
warm_upargument onadd_sim_timestamp, threaded throughEventLogger.generate_dfg, for discarding a warm-up period before building a process map- This is a plain time-based filter, not a port of
reshape_for_animations’warm_up.discover_dfgbuilds each case’s edges from its own consecutive rows rather than reconstructing who was present at a given moment from arrival and departure rows, so dropping early rows here cannot make a case silently vanish from output it should still appear in — the animation’s failure mode does not apply here - Two consequences worth knowing before relying on this for reporting: a case entirely within the warm-up is dropped completely, and a case that spans the cutoff loses the single edge connecting its last pre-cutoff event to its first post-cutoff event, since one side of that pair is no longer in the log. Both are intentional, so warm-up activity does not contribute to the transition statistics
- The default of
Nonekeeps every row, matching current behaviour exactly, and is a drop-in replacement for filtering the event log by time before callingadd_sim_timestamp, which is how this has been taught until now
- This is a plain time-based filter, not a port of
- Closed-set string arguments are now typed as literals, so editors offer the valid values and type checkers catch a typo before the call runs
backendandsimulation_time_unitongenerate_animationandanimate_activity_log,whatonTrialLogger.get_event_duration_statandplot_metric_bar, and the newsnapshot_alignmentandqueue_direction- The runtime checks are unchanged — annotations are not enforced, and a wrong value typed into a notebook still needs to raise
time_display_unitsis deliberately left untyped, since alongside its named options it accepts any custom strftime format
- New warning when an event log contains entities with no
arrivalevent- These are silently absent from every frame, because presence is decided by comparing arrival and departure times and a missing arrival compares as
Falseagainst every snapshot - Nearly always the signature of a log truncated to remove a warm-up period, so the warning names the entities, explains why they will not appear, and points at
warm_up - Both shapes are caught: an entity left with a
departrow but noarrival, and an entity still in the system whose remaining rows are all queue or resource events, which is absent from the arrival/departure pivot entirely
- These are silently absent from every frame, because presence is decided by comparing arrival and departure times and a missing arrival compares as
- New warning when
time_display_unitsis coarser than the snapshot interval- The animation frame is the formatted time, so e.g. ten-minute snapshots displayed as
'd'all carry the same label. Snapshots are merged, entities from different moments are drawn on top of one another, and plotly may produce no frames at all - This previously happened silently and returned a plausible-looking static figure
- The animation frame is the formatted time, so e.g. ten-minute snapshots displayed as
- New warning when an
event_position_dfmaps an event to more than one position, or places two different events at identicalx/ycreate_event_position_dfchecks the positions it is handed;generate_animation_dfchecks whatever reaches it, whether a hand-built DataFrame, a list of dicts or a dict of columns- A duplicated event was the worse of the two: entity snapshots are joined to their position on the event name, so every entity at that step is drawn in all of its positions at once and appears to jump between them at random
- Identical coordinates for different events is not corrupting but is almost always a copy-paste slip, invisible in the finished animation except as two stages drawn on top of each other
- Warnings only — a malformed
event_position_dfstill produces an animation, and a well-formed one is unaffected
- New warning when an event anchor falls outside an explicit
override_x_max/override_y_maxingenerate_animation/animate_activity_log- Those overrides become the axis bounds directly (the axis runs
[0, override]), so an anchor past them — or below 0 — is drawn off-canvas and that step’s whole queue / resource block silently disappears - Only checked when the override is actually passed; an auto-derived bound is
max(anchor) * 1.25/* 1.1and cannot be exceeded by construction - The warning names each offending event and its coordinate, and points at raising the override or moving the anchor
- Those overrides become the axis bounds directly (the axis runs
log_resource_use_start/log_resource_use_endgain an explicitevent=parameter, naming the specific step (e.g."treatment_begins") rather than the generic"start"/"end"default — needed to tell different resource-use steps apart inresource_use_intervals. This was already possible by passingevent=as an undocumented extra keyword argument, so behaviour for every existing caller is unchanged- New
logger=parameter onVidigiStore/VidigiPriorityStore, for automaticresource_use/resource_use_endlogging around resource acquisition and release, removing the need to bracket everyrequest()/get_direct()call withEventLogger.log_resource_use_start/log_resource_use_endby hand- Purely opt-in: omitting
logger=(the default) leaves every existing caller’s behaviour unchanged, and passingentity_id=to any of these methods on a store with nologgerdoes nothing request()/.get()(the context-manager pattern) auto-log both events givenentity_id=:resource_useonce the item is actually granted,resource_use_endin__exit__, right before the item is returned - the latter fires unconditionally, whether or not an exception was raised during resource use, since Python always calls__exit__on the way out of thewithblock.get_direct()/request_direct()(start) paired withput()/return_item()(end) get the same treatment for the manual acquisition pattern- The start event is logged via a callback appended to the get event immediately after it’s created, not synchronously when
request()/get_direct()is called -simpy.Event.succeed()only schedules an event for processing, it doesn’t invoke callbacks immediately, so this reliably captures the true grant time even when the request has to queue, and even alongVidigiPriorityStore’s immediate-availability path where.succeed()already ran synchronously. A request later abandoned viacancel_get()never fires this callback, so reneging produces no phantom start log - Default event names are derived from the pool’s
label(f"{label}_start"/f"{label}_end"), falling back to the same"start"/"end"literalslog_resource_use_start/log_resource_use_endthemselves already default to when the pool has no label. Overridable per call:start_event=/end_event=onrequest()/.get(),event=onget_direct()/put()/return_item()(meaning “start” on the former, “end” on the latter, since each of those only logs one side).pathway=and arbitrary**extra_fieldsare forwarded to the logged event(s) the same way - the auto-logging equivalent oflog_resource_use_start/log_resource_use_end’s own**extra_fields, so entity-level attributes (acuity=3,arrival_mode="ambulance", …) still reach the log as extra columns. Viarequest()/.get()the same values land on both the start and end event;get_direct()/request_direct()paired withput()/return_item()each take their own**extra_fields, for different fields per side or a value only known once the resource is released. Shown in the multi-server tutorial’s auto-logging section unique_resource_idis added automatically alongsideresource_idwhenever the pool was built withlabel=, matching the patternTrialLoggeralready recommends for a collision-proofby="resource"breakdown- If a logger is configured but
entity_idis omitted on a given call, auto-logging is skipped for that call, after a one-timeUserWarningper store (not per call) - so a model can deliberately mix auto-logging with manualEventLoggercalls without being warned on every one of the calls it wants to log itself - New
auto_log=parameter (defaultTrue) onrequest()/.get()/get_direct()/request_direct()/put()/return_item()for both stores: passauto_log=Falseto skip auto-logging for that one call without the missing-entity_idUserWarning, marking the omission as deliberate rather than a mistake. The intended use is keeping therequest()context manager’s automatic item return while writing thelog_resource_use_start/log_resource_use_endcalls by hand - e.g. to attach different fields to the start and end events, or a value only known once the resource is released. Per-call, so other requests on the same store keep auto-logging; a pool that is always logged by hand is still better served by not passinglogger=at all. Shown in the multi-server tutorial’s auto-logging section - Both stores are generic pools with no type constraint on what’s
put()into them; an item lackingid_attribute(e.g. one that slipped in via a more complex get/put pattern) degrades toresource_id=Nonein the auto-logged event rather than crashing the model with anAttributeError-EventLogger’s existing missing-resource_idwarning still surfaces the problem
- Purely opt-in: omitting
- New
filter_fn=parameter onVidigiStore/VidigiPriorityStorerequest()/.get()/get_direct()/request_direct(), for being granted only a pool unit matching a predicate — e.g.filter_fn=lambda r: r.grade == "senior"on a heterogeneous pool whose units carry agradeattribute — closes #116- Default
Noneaccepts any unit — a verified no-op.put()/return_item()are unchanged: which queued waiter a returned unit satisfies is decided from each waiter’s storedfilter_fn, not passed toput() VidigiStorenow wrapssimpy.FilterStoreinstead ofsimpy.Store.FilterStoreis aStoresubclass whose default filter accepts everything and grants in the same order, so with nofilter_fnthe two are equivalent — pinned bytest_against_core_simpy.py, which comparesVidigiStoreagainst a plainsimpy.StoreVidigiPriorityStoregets the same capability added to its hand-rolled priority queue: each waiter’s filter is stored on its queued request, and a freed or returned unit goes to the highest-priority queued request that accepts it. Sofilter_fnandprioritycombine — a lower-priority waiter whose filter matches is served ahead of a higher-priority waiter whose filter the unit fails, which is the point of the parameter- A
filter_fnthat never matches waits forever, like any unsatisfiable get. Combiningfilter_fnwith a finitecapacitysmaller than the number of items put is not fully supported (a matching unit can be parked in the put queue) — matching simpy’s ownFilterStorecaveat, whichVidigiStorenow inherits; the defaultcapacityis infinite - A non-callable
filter_fnraisesTypeErrorat the call site rather than failing later inside simpy or the queue walk - New example
examples/feat_filtered_resource_requests, a heterogeneous nurse pool where high-acuity patients request a senior nurse
- Default
VidigiStore.put()andVidigiPriorityStore.put()/return_item()now raiseTypeErrorif handed a SimPy event object orNoneinstead of a resource- The typical cause is a reneging or conditional-request branch passing the get/request event back into the pool instead of the item it yielded; the unfulfilled request then sits in the pool and is later handed to another entity, surfacing as an unrelated error far from the mistake
- Only these two unambiguous cases are rejected - the stores stay generic pools with no constraint that contents be
VidigiResource/simpy.Resource(see thelogger=note above) - The
request()/.get()context manager is unaffected: it only ever returns the exact item it was granted
VidigiResourcegainsidandunique_idas read-write aliases ofid_attributeandunique_id_attributeresource.idreads and writes the same value asresource.id_attribute; either name can be used at construction (VidigiResource(id=3)) or after.id_attribute/unique_id_attributekeep working exactly as before - no deprecation, and every example and doc that reads.id_attributeis unaffectedunique_idis present only when the pool was built withlabel=, exactly likeunique_id_attribute(accessing it otherwise raisesAttributeError, andhasattrisFalse)- Passing both names of a pair with different values (
VidigiResource(id_attribute=1, id=2)) raisesValueError - The tutorials and the
EventLogger-based example notebooks now teach.id/.unique_id;__repr__already printedVidigiResource(id=...), which the constructor now genuinely accepts. The pre-1.0 manual-event_log.appendexamples are left on.id_attribute
- New
extra_attributes=argument onpopulate_store,VidigiStore/VidigiPriorityStore(constructor and.populate()), for giving a whole pool of resources custom attributes without building it by handVidigiStore(env, num_resources=5, label="nurse", extra_attributes={"staff_type": "nurse"})setsresource.staff_type == "nurse"on every resource in the pool; your model code can then read it (break scheduling, skill mix, …) and it is otherwise inertVidigiResourcehas always accepted arbitrary keyword attributes directly (VidigiResource(id_attribute=1, staff_type="nurse")); this only threads them through the bulk populate helpers, replacing the documented workaround of monkeypatchingVidigiResource.__init__- Keys the pool manages itself -
id_attribute,id,label,unique_id_attribute,unique_id- are rejected with aValueErrornaming why - Defaults to
None, a verified no-op; existing calls are unchanged
- New
count,num_resourcesandn_waitingproperties onVidigiStore/VidigiPriorityStore, mirroringsimpy.Resourceso a store used as a fixed resource pool can be inspected the same waycountis the number of units currently in use (checked out of the pool);n_waitingis the number of get requests queued for one (len(simpy.Resource.queue));num_resourcesis the pool size — the running total passed tonum_resources=/populate(), including later top-uppopulate()callscountis computed asnum_resourcesminus the units currently in the store, not tracked per request, so it stays correct throughfilter_fnrequests, reneging viacancel_get, andVidigiPriorityStore’s direct holder-to-waiter handoff, with no per-get/per-put bookkeeping.0 <= count <= num_resources, the same invariant simpy keeps betweenResource.countandResource.capacity.capacityreturns the same value asnum_resourcesfor a default pool (see the breaking-changes note above); they differ only when an explicitcapacity=was passedcountraisesRuntimeErrorwhen the pool size is untracked — the store was filled by thepopulate_store()free function or by hand with.put()rather thannum_resources=/populate()— rather than silently returning a negative number.num_resourcesitself just reports what was tracked (0)- Passing an explicit
capacity=smaller than the pool can makecountover-report while returned units wait in the put queue — the same unsupported combination already noted forfilter_fn; the default container is unbounded
- New
plot_bgcolorandpaper_bgcolorarguments ongenerate_animationandanimate_activity_log, forwarded verbatim tofig.update_layout()plot_bgcolorsets the colour inside the axes,paper_bgcolorthe surround behind the title, play button and timeline; both accept any CSS colour string ("white","#f5f5f5","rgba(0,0,0,0)")- Saves reaching for
fig.update_layout(plot_bgcolor=...)on the returned figure, which was already possible and still works - Both default to
None, leaving the active Plotly template in control — a verified no-op, so existing calls are unchanged
- New animation enhancers
vidigi.animation.add_subplot_panels,add_synchronised_traceandadd_synchronised_trace_from_dataframe, for adding an extra chart or annotation that stays in step with the animation timeline- Previously this meant hand-editing
fig.frames— reproducing exactly how vidigi splits animated per-entity traces from static stage-label/resource traces. Getting it slightly wrong makes traces flicker, vanish after the first frame, or blank out the stage labels (as the olderexample_13notebook’s own comments record, and asexample_15’s bar panel had regressed to doing entirely) add_subplot_panels(fig, row_heights=[...])does theplotly.subplots.make_subplotsscaffolding — including the private_grid_refcopy that laterfig.add_trace(..., row=2, col=1)calls need — so the animation drops into the top row with empty panels beneath itadd_synchronised_trace(fig, frame_traces)takes aframe_traces(frame_name, frame_index)callable returning the trace(s) for that frame, plus optionalstatic_tracesshown identically throughout. It preserves the existing frame trace map, so stage labels and resource icons keep rendering, and raisesValueErrorif the callable returns a different number of traces on different frames rather than producing a ragged animationadd_synchronised_trace_from_dataframe(fig, data, make_trace, frame_time_col=...)is the convenience form for a long-form DataFrame:accumulate=Falsefeedsmake_traceone time step at a time (a snapshot),accumulate=Truefeeds everything up to the current frame (a cumulative line).match="index"(the default) pairs the i-th distinct data time with frameiregardless of howtime_display_unitshas relabelled it, and errors if the counts disagree — the silent failure mode behindexample_15redraw=Trueon the play button and slider is enabled automatically when it is needed (a non-scatter trace, or one on a secondary axis) and left alone otherwise; passredraw=explicitly to override- Internally,
add_repeating_overlay’s redraw-enabling block is now the shared_enable_frame_redrawhelper — no behaviour change
- Previously this meant hand-editing
- New
vidigi.ciw.event_logger_from_ciw_recsandvidigi.ciw.trial_logger_from_ciw_recs, converting ciw simulation records straight into a vidigiEventLogger/TrialLoggerrather than a bare DataFrameevent_logger_from_ciw_recs(recs, node_name_list=...)takes one run’sSimulation.get_all_records()and returns a populatedEventLogger, so a ciw model gets the same post-run surface a SimPy-plus-EventLoggermodel already has — event querying, JSON/CSV export,plot_entity_timeline,generate_dfg. Optionalrun_number=stamps every eventtrial_logger_from_ciw_recs(list_of_recs, node_name_list=...)takes several runs’ records (the shape amultiple_replications-style helper produces) and returns aTrialLogger— oneEventLoggerper run, numbered1..Nby default or viarun_numbers=— for cross-run duration, resource-utilisation, queue-size, replication and warm-up analysis. RaisesValueErrorfor an empty run list, arun_numberslength mismatch, or a run that recorded nothingevent_log_from_ciw_recsis unchanged: its per-record event building was factored into a shared private generator the three functions now share, and its DataFrame output is byte-identical (pinned by a new test)- Both loggers feed the animation functions via
.to_dataframe()exactly asevent_log_from_ciw_recsdoes
reshape_for_animationsandanimate_activity_lognow accept anEventLoggerorTrialLoggerforevent_log, not only a DataFrame —.to_dataframe()is called for you, so the helper call is no longer needed- New
run_numberargument picks one replication out of aTrialLogger; passing a multi-runTrialLoggerwithout it raises aValueErrorlisting the available runs run_numbergiven alongside a DataFrame or anEventLoggeris aValueError— it only means something for aTrialLogger- Passing a DataFrame is unchanged
- New
scenarioonanimate_activity_log/generate_animation(and thevidigi.analysis/vidigi.plots/TrialLoggerresource-utilisation helpers) now accepts a plain dict —scenario={"n_cubicles": 3}— as well as an object with attributes. The names inevent_position_df’sresourcecolumn (or aresource_map) are resolved as dict keys or object attributes interchangeablygenerate_animationnow emits aUserWarningwhen an event declares aresourcebut noscenariois passed, instead of silently drawing no resource-availability icons for that stage- Passing an object is unchanged; a dict is purely an additional accepted form
EventLoggerandTrialLoggernow take optionalscenario=andlabel=arguments, so the parameters that produced a set of runs and a human-readable name for them can travel with the logger — groundwork for scenario comparison, and for reliably saving a trial and its inputs togetherscenariois the same object-or-dict shape the animation and resource-utilisation helpers already accept. When one is attached to aTrialLogger,get_resource_utilisation/plot_resource_utilisation/plot_resource_utilisation_over_timeuse it automatically when noscenario=is passed to the call- A
TrialLoggerinheritsscenario/labelfrom its constituentEventLoggers when not given explicitly; a disagreement between runs warns and takes the first. An explicit argument always wins label(and whether ascenariois attached) is surfaced inTrialLogger.summary();labelis added toEventLogger.summary()vidigi.ciw.event_logger_from_ciw_recsandtrial_logger_from_ciw_recsgained matchingscenario=/label=passthrough arguments
- New
to_pickle()/read_pickle()onEventLoggerandTrialLoggerfor saving a populated logger (including any attachedscenario/label) to disk and loading it back- An
EventLoggerbuilt withenv=(the normal simpy pattern) previously could not be pickled at all — the liveEnvironmentholds generators. Theenvis now dropped on pickle (it is only read while logging), so a restored logger is a complete, finished record withenv=None - An attached
scenariothat is not itself picklable — one holding a live simpyEnvironmentor aStore— raises with a message naming it as the likely cause
- An
- New
occupancy_metrics=onEventLogger.generate_dfg(), and anoccupancy_stats=argument onvidigi.process_mapping.discover_dfg, for annotating a process map’s nodes with how many entities were present at each step — queue build-up and resource load, alongside the frequency and timing statistics the graph already carries. Closes #176generate_dfg(occupancy_metrics=True)computes the figures withvidigi.analysis.activity_occupancy_stats(see New metrics) and merges them onto the node table;occupancy_snapshot_interval=sets the snapshot granularity. Off by default because the queue half runsreshape_for_animationsonce per run — the reason issue #176 asked for it to be optional- Each queue or resource node then gets an extra label line —
avg queued 3.2 (min 0.0, max 9.0)/avg in use ...— across all four output formats. Newshow_occupancy=(defaultTrue) ondfg_to_graphviz,process_nodes_and_edges_for_cytoscape,dfg_to_cytoscapeanddfg_to_cytoscape_streamlitsuppresses it; on a node table without occupancy columns it is a no-op, so nothing changes for a caller who does not opt in warm_uppassed togenerate_dfgis applied to the occupancy calculation too, on the raw (pre-filter) log so arrival rows survive forreshape_for_animations
- Added a
CITATION.cfffile, a Citation section in the README and a “Citing vidigi” documentation page, giving the Journal of Simulation paper (Rosser & Chalk, 2026, doi:10.1080/17477778.2026.2663849) as the preferred citation, with the Zenodo archive for citing specific releases- The README Zenodo DOI badge now points at the all-versions (concept) DOI so it tracks the latest release rather than staying pinned to v1.3.1
- New
animate_activity_log()andreshape_for_animations()methods onEventLoggerandTrialLogger, so a populated logger can go straight to an animation without importing the module function or calling.to_dataframe()first (logger.animate_activity_log(event_position_df, ...))- Both are thin wrappers over the existing
vidigi.animation.animate_activity_log/vidigi.prep.reshape_for_animations, which already accepted a logger forevent_log— this is the method-form sugar on top - On
TrialLogger,run_number=selects the replication to animate; a multi-run trial without it raises the sameValueErrorlisting the runs.TrialLogger.animate_activity_logalso falls back to the trial’s attachedscenariowhen none is passed, like theplot_resource_utilisationdelegators reshape_for_animations()is the entry point to the manual three-step pipeline;generate_animation_df/generate_animationstay as functions since they act on the intermediate DataFrame, not the logger — the method docstring shows the full sequence
- Both are thin wrappers over the existing
- New
TrialLogger.generate_dfg(), the trial-level counterpart toEventLogger.generate_dfg(), so a multi-run trial no longer has to be reduced to one run by hand before it can be drawn as a process map- Three routes: the default is the representative run — the replication whose mean time in system is closest to the trial median (
Run K of Min the title);run_number=Nrenders one named replication, delegating to that run’s ownEventLogger.generate_dfg;across_runs=Truebuilds one combined cross-run map - The cross-run map groups transitions per
(run, entity)so no edge is fabricated between the last event of one replication and the first of the next. Node counts and edge frequencies are shown per replication (pooled total ÷ number of runs) with the between-run range —n=3.5 (1–7)— and transition times are pooled over every run, with the mean also carrying the between-run spread of the per-run mean (mean=42.0 minutes (35.0–51.0 across runs); a range, not a CI —get_event_duration_ciremains the route to a formal interval).occupancy_metrics=Trueusesactivity_occupancy_stats(across_runs="average") - Graphviz output gets an auto
title(“M replications combined — simulation output, not observed data. Counts are per-run means.”); the cytoscape renderers get the same text as acaption.across_runs=Truewith nowarm_upwarns, since start-up transient then feeds a stakeholder-facing aggregate vidigi.process_mapping.discover_dfggainsrun_col_name=for the same run-aware grouping, and now emits aUserWarning(previously silent) when handed a log spanning more than one run without it — it builds transitions per case regardless of run, so a concatenated multi-run log fabricates cross-run edges. Default single-run output is a verified byte-for-byte no-op; the warning is flagged to raise in vidigi 3.0- New
show_between_run_ci=/caption=ondfg_to_graphviz/process_nodes_and_edges_for_cytoscape/dfg_to_cytoscape/dfg_to_cytoscape_streamlitfor the range annotations and caveat text; both no-ops on a single-run graph
- Three routes: the default is the representative run — the replication whose mean time in system is closest to the trial median (
- Lowered the minimum
pandasto 1.5.3 (was 2.0.1) and the minimumnumpyto 1.24.0 (was 1.26.2), so vidigi installs alongside an older scientific-Python stack- An audit of every pandas/numpy call in the library found nothing that needs the previous floors — the numpy API used all predates 1.20, and no pandas 2.x-only feature is used. The old bounds had been in place, unexplained, since the first commit
- In practice this only widens the resolver’s choices on Python 3.10 and 3.11; on 3.12+ pip already picks a newer pandas/numpy that ships wheels for that interpreter, regardless of this floor
- Verified once against the floor (
pandas==1.5.3,numpy==1.24.0) with the full test suite; a continuous minimum-version CI job is still to be added, so thetoxmin-versions/min-numpy-pandas-simpyenvironments (updated to match) are the check until then - Dropped the
packagingdependency, which was no longer imported anywhere — it had been added for a pandas-2.2 version gate that was later replaced with a version-agnostic approach
- Raised the maximum
pandasto<4.0.0(was<3.0.0), so vidigi installs alongside pandas 3.x- Pandas 3.0 (released 2026-01-21) changes several defaults library code can be sensitive to — string columns now infer to
strdtype instead ofobject, Copy-on-Write is the only mode (chained assignment likedf[...][...] = xstops working), and datetime resolution is inferred rather than always nanosecond. An audit found no object-dtype branching and no chained assignment anywhere insrc/vidigi— all setitem already goes through.loc[mask, col] = .../.assign(...), which is CoW-safe - Fixed two lowercase
pd.to_timedeltaunit strings ("d","w") ingenerate_animation_df’s day/week/month/year handling that pandas 3.x warns are deprecated in favour of"D"/"W" - Verified with the full test suite against a new
toxmax-versionsenvironment (pandas>=3.0,<4.0.0, unpinned otherwise)
- Pandas 3.0 (released 2026-01-21) changes several defaults library code can be sensitive to — string columns now infer to
New metrics
- New
vidigi.analysismodule — the first piece of a numbers-in-DataFrames-out layer that the plotting functions will sit on top ofevent_durations(event_log, first_event, second_event, match=...)pairs occurrences of two events per entity and computes the time between them, usable standalone on any event log, including one where an entity revisits a stepmatchcontrols how repeated occurrences are paired:"first"/"last"take the entity’s earliest or latest of each event regardless of how many times either occurs;"occurrence"pairs the n-th of each in time order, and warns if an entity has an unequal count of the two- The pairing is an outer join, not a left join on the first event, so it captures both an entity that started but never finished, and one that finished with no matching start
pathwayandrun_numberare always present in the output, even when the input log has neither column, sinceEventLogger.to_dataframe()drops all-null columns
- BREAKING:
TrialLogger.get_event_duration_statand the newTrialLogger.get_event_durationsare now built onvidigi.analysis.event_durationsinstead of apivotpivotraisesValueError: Index contains duplicate entriesfor any entity that revisitsfirst_eventorsecond_eventwithin a run - a rework loop - so those logs could not be analysed at all. This is now supported via the newmatchargument- At the default
match="first", results are identical to the old pivot everywhere it used to succeed - the only behaviour change is that logs which previously raised now return a value get_event_duration_stat’s per-run denominator (used byserved_count_mean_per_runandunserved_count_mean_per_run) is now the true number of runs in the trial rather than only those containing the event pair - see the breaking change above- New
TrialLogger.get_event_durations(first_event, second_event, match=...)exposes the full per-entity duration frame directly, rather than only a single aggregated statistic
- New
[stats]optional extra (pip install vidigi[stats], pulling inscipy>=1.10), and two newvidigi.analysisfunctions building towards confidence intervals across replicationsreplication_means(durations, what=...)reduces a per-entity durations frame (e.g.event_durations’s output) to one value per run - the independent unit any interval must be computed overmean_confidence_interval(values, ci_level=0.95)computes a confidence interval over those replication-level values using Student’s t withn - 1degrees of freedom, never a normal approximation - atn=5, a typical replication count,zis 29% too narrow. Only this function needsscipy; it is not imported anywhere else, and raisesImportErrornamingpip install vidigi[stats]if missing- Neither function accepts pooled per-entity observations disguised as replications: entities within a run are strongly serially correlated, so an interval computed that way can be roughly 30x too narrow.
replication_meansrejects entity-counting aggregations ("count","unserved_rate","summary", …) for the same reason - they answer “how many”, not “what value”, and are not meaningful re-averaged across runs
- New
across=parameter onTrialLogger.get_event_duration_stat, and a newTrialLogger.get_event_duration_ci, putting the replication-level summary of a duration one method call away rather than a hand-assembledevent_durations→replication_means→mean_confidence_intervalchainget_event_duration_stat(..., across="runs")computes the chosen statistic within each run, then averages those per-run values - weighting every replication equally rather than by its entity count. The defaultacross="entities"is unchanged (a verified no-op): the pooled statistic over every entity, run boundaries ignored, exactly as every prior release gave.across="runs"mirrorsplot_metric_bar’s parameter of the same name, including rejecting entity-countingwhatvalues ("count","summary", …) and requiringexclude_incomplete=Trueget_event_duration_ci(first_event, second_event, what="mean", ci_level=0.95)returnsmean_confidence_intervalover those per-run values as aConfidenceIntervalnamedtuple - the headline “what is this number, and how sure are we” figure for a trial. Distinct fromget_replication_precision, which reports how that interval tightens as replications accumulate rather than the single interval from every replication in the trial. Needs the[stats]extra, like every other confidence interval in vidigi- New guide, Choosing how to summarise across replications, laying out the pooled / per-replication / equal-weight-with-zeros conventions vidigi uses, which each
TrialLoggermethod applies, and when each is the one you want
- New
vidigi.analysis.resource_use_intervals(event_log, ...), pairingresource_use/resource_use_endrows into one interval per bout of resource use — the first vidigi function able to answer “how busy was this resource”- Splits on
event_type, notevent, since a bout’s start and end rows are named differently (e.g."treatment_begins"/"treatment_ends"); the start row’s event name is what identifies the step - An entity still holding a resource when the analysis window ends is censored by default (
unclosed="censor"): its interval is clipped to the window end rather than dropped. Dropping understates utilisation exactly when it matters most, since entities still holding a resource at the end of a run are disproportionately those in a congested system — the same failure mode as theplot_queue_sizebugs fixed earlier in 2.0.0.unclosed="drop"opts out - An end row with no matching start (a logging defect) is always dropped, with a warning
- A log with no
resource_idat all falls back to pairing on(run, entity), with a warning —busy_time/mean_in_use/utilisationstay exact, only the per-unit breakdown is lost. A log withresource_idon some resource-use rows but not others raises, since pairing would otherwise silently cross entities using different physical units
- Splits on
- New
vidigi.analysis.resource_utilisation(event_log, by=..., ...), aggregating those intervals into busy time, mean-in-use and utilisation — always one row per run per group, matchingreplication_means’s “aggregation across runs is the plotting layer’s job” conventionby="step"(default) or"resource"(one physical unit, capacity always1) — or"run", pooling every step/unit together, wherecapacityis the sum of every pooled step’s capacity (NaNif any is unresolved). Pooling more than one distinct step warns: a blended busy-time/utilisation figure across resource types (e.g. doctors and beds summed together) is rarely the number a capacity-planning question is asking, even when it is arithmetically well-definedby="resource"assumesresource_idis unique across the whole log, not just within one step — two independent resource pools that each number their own units from 1 (e.g. two separatevidigi.resources.VidigiStores) have their busy time silently summed as if they were one physical unit, which can pushutilisationabove1with no error beyond the generic over-capacity warning. Documented in the docstring; found while writing thefeat_trial_loggerexample notebook against a six-resource-type modelby="resource"now also warns directly when it finds two bouts for the sameresource_idgenuinely overlapping in time within a run — impossible for one physical unit, and a sharper, root-cause-naming signal of an ID collision than the generic over-capacity warning alone. The grouping key itself is deliberately unchanged (still bareresource_id, not(step, resource_id)) — grouping by step too would silently split one physical resource legitimately reused across several step names into several rows instead, trading one silent failure mode for a worse one- New opt-in
label=parameter onvidigi.resources.VidigiStore(__init__/.populate()),populate_store(), andVidigiPriorityStore(__init__/.populate()). When given, each resource additionally gets.label(the pool’s label) and.unique_id_attribute(f"{label}_{index}", unique across pools when every pool is given a distinct label) —id_attributeitself is completely unchanged, sincevidigi.prep’s animation positioning depends on it staying a small per-pool index. Logunique_id_attributeunder a separate field name (e.g.unique_resource_id, vialog_resource_use_start/_end’s existing**extra_fields) and passresource_col_name=toresource_utilisation/resource_use_intervalsto get a collision-proofby="resource"breakdown. Omittinglabelis a verified no-op on the resources produced, but now emits aDeprecationWarning—labelis planned to become mandatory at vidigi 3.0, recorded inpending_fixes.md - Fixed, found by an independent review of the above: the missing-
labelDeprecationWarning’sstacklevelunder-counted by one forVidigiStore/VidigiPriorityStore’s constructor path (__init__calls.populate()internally, adding a framepopulate_store()and a direct.populate()call don’t have), so it always attributed to the internal.populate()call insideresources.pyrather than the caller’sVidigiStore(...)line. This was not just a cosmetic mislabelling: Python’s default warning filter suppresses repeats sharing the same(message, category, module, lineno), so two separate unlabelled pools constructed viaVidigiStore(...)/VidigiPriorityStore(...)both collapsed onto that one internal line and only warned once between them - fixing the first pool a reviewer’s warning pointed at left the second silently unflagged - New check: constructing two resource pools with the same
labelon the samesimpy.Environmentnow warns, since it reproduces the exactresource_idcollisionlabel=exists to prevent, silently, with no other safety net unless the two pools happen to be busy at the same instant. Deliberately scoped per environment rather than globally - reusing a label across separate replications (a freshsimpy.Environmentper run) is normal and must not warn, or every run after the first would falsely flag itself - Fixed a related bug this surfaced:
resource_use_intervals’sresource_col_name=/entity_col_name=/run_col_name=pointing at a column other than the canonical default (e.g.resource_col_name="unique_resource_id"on a log that also carries a literalresource_idcolumn, logged separately for animation) used to raiseValueError: The column label 'resource_id' is not unique, since the internal rename produced two identically-named columns. The rename now drops any pre-existing column sharing a target name first - BREAKING:
TrialLogger.get_resource_utilisation(),TrialLogger.plot_resource_utilisation()andTrialLogger.plot_resource_utilisation_over_time()gained the sameresource_col_name=parameter, now defaulting toNone: this resolves to"unique_resource_id"if that column is present on the trial’s log, else"resource_id"(deliberatelyNonerather than a magic string likerun_col_name’s existing"auto"sentinel elsewhere in this codebase -resource_col_namehas no pre-existing meaning forNoneto collide with, so there is no ambiguity with a log that genuinely has a column literally named"auto"). So a model built the recommended way -VidigiStore(..., label=...), loggingunique_resource_idalongsideresource_id- gets a collision-proofby="resource"breakdown fromTrialLoggerwith no extra argument anywhere, while a model with nounique_resource_idcolumn at all behaves exactly as before. Pass an explicit column name to override. One real edge case changes results for a caller who changes nothing: a trial whereunique_resource_idis present on some resource-use rows but not others (e.g. only part of a model’s logging was updated to the new pattern) used to succeed under the old hard-codedresource_iddefault; it now raisesValueError, sinceresource_use_intervalsalready refuses to pair on a partially-populated column rather than risk silently crossing entities using different physical units -resource_col_name="resource_id"restores the old behaviour explicitly. All three methods also gained a**kwargspassthrough for the remaining column-name family (entity_col_name,time_col_name,event_type_col_name,event_col_name,run_col_name) - previously unreachable, and onplot_resource_utilisation_over_timeentirely absent - closing a gap an independent review found: the commit that first addedresource_col_name=to two of these three methods left the third behind on the same day, which is exactly the kind of drift hand-threading each parameter individually invites. The freevidigi.analysis/vidigi.plotsfunctions are deliberately left untouched - explicitresource_col_name="resource_id"by default, no auto-detection - since they are meant to work on any log, not just one produced byEventLogger/VidigiStore mean_in_useneeds no capacity at all (busy_time / window_length) and is always populated;utilisationadditionally divides by capacity and isNaNwherever that is unresolved. A resolvedutilisationover1always warns — this definition of utilisation can never legitimately exceed1, so it is a live signal of a resolution or logging problem worth surfacing immediately rather than only visually once a plotting layer exists- A
(run, group)combination absent from a specific run but present in another (a resource that happened not to be used that run) reports a genuinebusy_timeof0there, not a missing row — the same “real zero” conventionqueue_size_over_timealready uses. This also coversby="resource"whenresource_idis missing from the whole log: rather than the per-unit breakdown collapsing to zero rows, it reports one pooled row per run, with a warning
- New
_resolve_resource_capacities, resolving a{step: capacity}mapping from four routes, in precedence order: an explicitresource_capacities={step: count}dict;scenario=withresource_map={step: "attribute_name"};scenario=withevent_position_df=(reusing theresourcecolumn already used by the animation functions); orcapacity="infer", which estimates each step’s capacity as the number of distinctresource_ids seen for it — a lower bound (a never-used unit is invisible), so it always warns- Passing nothing at all is not an error —
utilisationis simplyNaNthroughout, withmean_in_usestill fully populated scenariogiven without one of the three ways to use it raises, naming all three with an example each; aresource_map/event_position_dfnaming an attributescenariodoes not have raisesAttributeErrornaming the attribute, the step, and every available attribute onscenario- The
event_position_dfroute reuses a new shared helper,vidigi.utils._resource_map_from_event_position_df, extracted fromanimation.py’s pre-existing resource-icon lookup so the two cannot drift on what counts as “this event has a resource”. The extraction is behaviour-preserving — every existing animation test passes unchanged, which is its proof
- Passing nothing at all is not an error —
- New
TrialLogger.get_resource_utilisation(), a thin delegator tovidigi.analysis.resource_utilisationcalled on the trial’s combined dataframe - New
vidigi.analysis.resource_occupancy_over_time(event_log, ...), the resource equivalent ofqueue_size_over_time— how many units of each step were busy at regular snapshots, across every run- Deliberately not built on
reshape_for_animations— occupancy is interval containment (“was this bout still open at this snapshot?”), a different question fromreshape_for_animations’ “most recent event per entity wins”. Computed exactly via a +1/-1 sweep overresource_use_intervals’s bouts (sorted, cumulatively summed, then looked up onto the snapshot grid withsearchsorted) rather than a per-snapshot membership scan - A bout is occupied on the half-open interval
[start, end)— a unit freed exactly at a snapshot time is not counted as busy there. Always censors an unclosed resource use through to the window end (there is nounclosedparameter), for the same reasonresource_use_intervalscensors by default - A
(run, step)combination with nothing in use at a snapshot reports a genuinecountof0, not a missing row, matchingqueue_size_over_time’s convention
- Deliberately not built on
- New
vidigi.analysis.welch_moving_average(series_by_run, window, method=...), vidigi’s first tool for choosingwarm_up=rather than just applying it — seeplot_warm_up_diagnosticunder New plots for how it’s visualisedmethod="welch"(the default) is Welch’s (1983) moving-average procedure, as described in Law, Simulation Modeling and Analysis: replications are ensemble-averaged at each index, then smoothed with a symmetric window. The firstwindowpoints, where a full-width window would run off the start of the series, use the narrower symmetric window that actually exists (2i - 1points) rather thanNaN— the detail apandas.rolling(center=True)call gets wrong — and the lastwindowpoints, which have no full-width neighbourhood at all, are simply not returned, so the output iswindowpoints shorter than the input.method="cumulative"is the plainer expanding mean of the ensemble average instead — simpler, needs no window, but each new point only shifts it by1/i, so it decays a biased early transient out far more slowly than Welch’s fixed-width window does; the risk is not that this curve is rougher, but that it is smoother in a way that can look settled long before the series actually has- An independent OR-specialist review confirmed the shrinking-edge/truncated-tail math and the no-automatic-selector decision against Law & Kelton’s own treatment, corrected the “noisier” mischaracterisation above, and found a real gap this release also closes:
series="duration"’s reading was in entity count, but nothing consuming duration data had awarm_up=to apply it to. See the newevent_durationsparameter directly below - New
method="none": the ensemble average with no smoothing applied at all - a further step beyondmethod="cumulative"(the “time series inspection” technique the DES RAP book, Heather et al. 2026, actually demonstrates - see that method’s own entry). Noisier than either other method by construction, since nothing here reduces the within-replication variance the ensemble average didn’t already remove - useful specifically when you’d rather see that noise than have it smoothed away. Drawn the same wayshow_ensemble’s reference line already was, soshow_ensembleis a no-op undermethod="none"rather than drawing the identical line twice - A citation-correctness review found this had misattributed the DES RAP book’s demonstration to
method="none"- the book’s own text explicitly plots a cumulative mean (“we plot the cumulative mean… look for the point where this smoothes out and stabilises”), which matchesmethod="cumulative", not the fully unsmoothedmethod="none". The same review found the Robinson citation’s year was wrong: no primary bibliographic source (Wiley, AbeBooks, Internet Archive, ACM Digital Library) shows a 2007 printing of Simulation: The Practice of Model Development and Use - the first (Wiley) edition is dated 2004. Both corrected throughout the docstrings and the example notebooks;method="cumulative"’s docstring now carries the DES RAP citation instead
- New
warm_upparameter onvidigi.analysis.event_durations, threaded through everything built on it —plot_duration_distribution,plot_metric_bar, andTrialLogger.get_event_durations()/.get_event_duration_stat()/.plot_duration_distribution()/.plot_metric_bar()— so a cutoff read offplot_warm_up_diagnostic(series="duration")has somewhere to go- A pairing is excluded when its
first_timeis beforewarm_up— discarded by when it started, the standard truncation rule for duration data (Law & Kelton), not by whether it later straddles the cutoff. This is a genuinely different rule fromresource_use_intervals/resource_occupancy_over_time’swarm_up, which censors/clips a bout mid-interval rather than excluding it outright — a duration is one atomic observation, not something that can be partially inside the window - Filters per pairing, not per entity: with
match="occurrence", an entity’s early occurrence during warm-up is excluded while a later occurrence of the same entity afterwards is kept - A pairing with no
first_timeat all (asecond_eventwith no matchingfirst_event) is never excluded bywarm_up, since there is no time to compare it against - The default of
0is a verified no-op. Onplot_duration_distribution(both the free function andTrialLogger’s), it reachesevent_durationspurely through the existing**kwargscolumn-name passthrough, with no new named parameter needed;plot_metric_bar(both) gained an explicitwarm_up=parameter instead, since its**kwargsis plotly passthrough, not column-name passthrough — the one function in this module where that distinction matters
- A pairing is excluded when its
- New
vidigi.analysis.replication_precision(values, ci_level=, deviation_threshold=)andTrialLogger.get_replication_precision(...), the counterpart towelch_moving_averagefor “how many replications is enough” rather than “how much warm-up to discard”- For k = 1…n replications, in the order given (deterministic —
run_numberorder, since a cumulative diagnostic only means “as replications accumulate” walked through generation order), reports the cumulative mean, its confidence interval (mean_confidence_interval) computed from only the first k replications, and the relative half-width (deviation = half_width / abs(cumulative_mean)) — the standard precision diagnostic (Hoad, Robinson & Davies, 2010) for deciding when enough replications have been run stays_below_thresholdmarks row kTrueonly ifdeviationis defined and stays at or underdeviation_threshold(default 5%) from k all the way to n — deliberately “stays below”, not “first drops below”: a noisy early curve can dip under the threshold once by chance and rise again, which would be a spurious recommendation. The smallestn_replicationswithstays_below_threshold=Trueis the recommended minimum replication count; alwaysFalseat k=1, where deviation is undefined- Scoped to duration event-pairs only (
first_event/second_event, matchingplot_metric_bar’s shape) rather than also covering queue/occupancy series the wayplot_warm_up_diagnostic’sseries=does — those don’t have a single natural per-replication scalar without picking a snapshot rule first, so extending this to them is left for a future release rather than guessed at now - An independent OR-specialist review (of this still-unreleased feature) found
deviationwas computed ashalf_width / cumulative_meanwith noabs()— harmless for the naturally-positive duration metrics this function is scoped to, but for a metric with a negative mean (e.g. a before/after difference passed through the samevaluesparameter) the ratio came out negative and trivially satisfied<= deviation_threshold, sostays_below_thresholdcould flagTrueon a wildly imprecise interval. Fixed before release, no caller-visible change for any positive-mean metric. The same review also prompted a clarification now in the docstring and the example notebook:stays_below_thresholdis a property of the batch of replications actually supplied, not an open-ended guarantee — intended as “read this diagnostic with judgement,” matching the same stance already taken forwelch_moving_average’s deliberate lack of an automatic selector. A follow-up check against the cited paper’s primary text found a second inaccuracy in that same clarification as first written: it claimed the paper makes no correction for repeatedly testing at every k, but Hoad, Robinson & Davies (2010) address exactly this “early convergence” risk with their own “look ahead” (kLimit) mechanism, empirically shown in the paper to eliminate the coverage failures a naive first-crossing rule produced. Corrected to describestays_below_thresholdaccurately as a simpler, unbounded stand-in for that same idea — not a literal implementation ofkLimit, and, like the paper’s own procedure, validated only empirically rather than derived as a formal statistical correction
- For k = 1…n replications, in the order given (deterministic —
- New
vidigi.analysis.entity_metric_by_arrival(event_log, first_event, second_event, arrival_event=, match=, ...)andTrialLogger.get_entity_metric_by_arrival(...)— a per-entity duration (event_durations’s own output) joined with each entity’s arrival time, for spotting whether a metric drifts depending on when the entity arrived - a non-stationary arrival process or a time-of-day/load effect, for example (see New plots for the matching chart)arrival_eventis deliberately independent offirst_event/second_event— it can coincide withfirst_event(e.g. measuring time from arrival itself), but does not have to; this answers a different question from “how long did this specific interval take” (“does this duration vary depending on when the entity showed up at all”)- The arrival lookup always uses the entity’s earliest occurrence of
arrival_event, regardless ofmatch— an entity ordinarily arrives once, somatchonly ever governs howfirst_event/second_eventare paired, never the arrival lookup. The join keying the two frames together is on(run_number, entity_id)only, notoccurrence, so every occurrence-row for one entity undermatch="occurrence"shares the samearrival_time - An entity with a complete duration pairing but no
arrival_eventrecorded in that run getsarrival_time = NaN, not a dropped row — matchingevent_durations’s ownkeep_incompletephilosophy
- New
vidigi.analysis.activity_occupancy_stats(event_log, ...), reducing the per-snapshot occupancy series to the mean / min / max / median entities present at each step — one row per queue step and per resource step, built for merging onto a directly-follows graph’s nodes (seegenerate_dfg(occupancy_metrics=True)under New features)- Reuses
queue_size_over_timefor queue steps andresource_occupancy_over_timefor resource steps rather than rescanning snapshots — so it inherits their “real zero, not a missing row” convention and the uncapped queue length across_runs="average"(default) takes each statistic within a run and averages the per-run values (the figure expected per replication);across_runs="pool"takes one statistic over every(run, snapshot)count (somaxis the worst seen in any run). A single-run log gives the same answer either way- An event name logged as both a
queueand aresource_usestep is reported as a queue only, with a warning — the two occupancy questions cannot share one node
- Reuses
- New
vidigi.analysis.compare_replication_values(values_a, values_b, ...),TrialLogger.compare_event_duration_stat(other, ...)andTrialLogger.compare_resource_utilisation(other, ...)— a scenario comparison highlighter for twoTrialLoggers, comparing a duration or resource-utilisation metric between them (closes #153, see New plots for the matching chart)- Computes each side’s
mean_confidence_intervalindependently (never pooled or paired — the two samples are two different scenarios’ replications, not before/after pairs of the same run), plus a Welch’s t-test p-value (scipy.stats.ttest_ind(..., equal_var=False)) as a more rigorous companion figure ci_overlap=Falseis a safe “these two scenarios differ” signal — two independent confidence intervals failing to overlap is a stricter condition than a two-sample significance test at the sameci_level.ci_overlap=Truedoes not prove “these are the same”, only “not conclusively different by this simple check” —p_valueis the figure to read alongside it, not a replacement for itcompare_event_duration_statcompares a duration statistic (built onget_event_durations+replication_means, likeget_event_duration_ci);compare_resource_utilisationcomparesutilisation/busy_time/mean_in_use, always pooled across every step/resource into one blended per-run figure (get_resource_utilisation(by="run")) — callget_resource_utilisation(by=...)on each trial directly and pass the result intocompare_replication_valuesto compare one specific step or resource insteadlabel_a/label_bdefault to each trial’s own.label, falling back to"A"/"B"if neither has one
- Computes each side’s
- New
vidigi.analysis.flag_outlier_runs(replication_values, iqr_multiplier=1.5)andTrialLogger.get_outlier_runs(first_event, second_event, ...)— outlier run identification for a trial’s replications (closes #153)- Tukey’s fence (
Q1 - iqr_multiplier * IQR/Q3 + iqr_multiplier * IQR) applied as a threshold — the same conventionerror_bars="iqr"onplot_metric_bar/plot_resource_utilisationalready uses to draw an error bar, just as a flag rather than a visual. Deliberately the only method offered — nomethod=selector - Returns the full per-replication table with
lower_fence/upper_fence/is_outliercolumns added, so a caller sees why a run was (or was not) flagged, not just which ones were. Warns (does not raise) below 4 replications, since quartiles are not very meaningful with fewer
- Tukey’s fence (
- New
vidigi.analysis.event_occurrence_rate(event_log, event_name, ...)andTrialLogger.get_event_occurrence_rate(event_name, ...)— a rare-event estimate: the proportion of runs (not entities) in which an event occurs at all (closes #153)- Distinct from
get_event_duration_stat’s existing"unserved_rate"/"served_rate", which are per-entity rates within one event pair; this is a per-run rate for a single event, for a condition that either happens or doesn’t in a given run (a capacity breach, a specific alarm) - The interval is a Wilson score interval, not
mean_confidence_interval’s Student-t — a proportion’s interval needs to stay within[0, 1]and behave sensibly near0or1, exactly where a rare-event rate typically sits. There is deliberately nohalf_widthfield (unlikeConfidenceInterval): a Wilson interval is asymmetric near the boundaries, so a single half-width would misrepresent it. Unlikemean_confidence_interval, there is no low-nwarning — Wilson is well-defined for anyn_runs >= 1, includingn_occurredof0orn_runs TrialLogger.get_event_occurrence_ratealways passesn_runs=len(self._event_logs)— the trial’s true run count — rather than inferring it from which runs happened to log the event, the same denominator correctness this section’sget_event_duration_statbreaking change addressed forunserved_rate/served_rate- An event name matching nothing in the log is not an error here, unlike
event_durations’s_check_events_presentguard — a rare event legitimately occurring zero times in the runs available is exactly theproportion=0.0result this function exists to report
- Distinct from
New plots
- New
vidigi.plotsmodule —go.Figure-returning charts, each a thin wrapper over the matchingvidigi.analysisfunctionplot_queue_size(event_log, event_list, limit_duration, ...)is the first entry, extracted fromTrialLogger.plot_queue_size. It operates on a plain event log DataFrame rather than aTrialLogger, so it also works on logs fromvidigi.ciw, a CSV, or a hand-built frame- Its data preparation is
vidigi.analysis.queue_size_over_time(event_log, event_list, limit_duration, ...), giving queue-size-over-time a “numbers only” route for tables and reports as well as a chart **kwargskeeps its existing meaning - forwarded straight toplotly.express.line- rather than being repurposed for column-name overrides, so no existing caller’s styling kwargs silently start doing something else. Column names (entity_col_nameand friends) are separate, explicitly named parameters on both functions
TrialLogger.plot_queue_sizegains awarm_upargument, for discarding a warm-up period the same wayreshape_for_animationsalready supports- The default of
0is a verified no-op - output is identical to omitting the argument - Internally,
TrialLogger.plot_queue_sizeis now a thin delegator tovidigi.plots.plot_queue_size, called on the trial’s combined dataframe. The four tests asserting exact queue-length values were left untouched, and passing unchanged is the proof the extraction is behaviour-preserving
- The default of
- New
backendargument onplot_queue_size(bothvidigi.plots.plot_queue_sizeandTrialLogger.plot_queue_size), in response to feedback from advanced users who wanted aplotly.graph_objects-built chart to restyle afterwardsbackend="express"(default) is the pre-existing behaviour, unchanged -**kwargsstill forwards toplotly.express.linebackend="go"builds every trace explicitly instead: trace names, order and legend grouping are then deterministic rather than depending onpx’s automatic per-run grouping, which is easier to target when restyling a specific run or event’s trace afterwards. It also sets facet titles directly viaplotly.subplots.make_subplots, with no need for theevent=prefixpx’s auto-generated annotations require stripping off.**kwargsis not used by this backend and is ignored with a warning if passed- Matches the accepted spellings and case-insensitive matching of
backendon the animation functions ("express"/"px"/"plotly express","go"/"graph objects"/"plotly graph objects"/"plotly go"), for consistency across the package - Purely additive: no existing caller’s output or
**kwargsbehaviour changes - New
highlight_bands=, added later in 2.0.0 alongsideplot_metric(see below) - the shaded-threshold-zone feature, applied here regardless ofbackend, and spanning every facet when more than one event is plotted
- New
plot_duration_distribution(bothvidigi.plots.plot_duration_distributionandTrialLogger.plot_duration_distribution) - vidigi’s first chart showing the shape of a duration rather than a single summary statistickind="hist"(default),"box","violin","ecdf","ridgeline"or"heatmap". Histograms are numpy-binned and drawn asgo.Bar, neverplotly.graph_objects.Histogram- that bins in the browser, so itsyvalues are never inspectable, in code or in a test."ecdf"is drawn as a step line, since linear interpolation between sorted points would draw cumulative probabilities that never occurredsplit_by="run"or"pathway"draws one trace per distinct value of that column instead of pooling every duration together, using the same bin edges across groups forkind="hist"so bars stay comparable"ridgeline"and"heatmap"both requiresplit_by, and exist for the casesplit_bywas built for but a plain"box"/"violin"handles badly: comparing a duration’s distribution across many groups (e.g. every run in a 100-replication trial) without the chart turning into an unreadable pile."ridgeline"stacks one density curve per group with a slight vertical overlap;"heatmap"draws duration on the x-axis and one row per group, coloured by count or density, and scales further still since it costs no vertical space per row at all. Ridgeline heights are always a per-group density, never a raw count, so a group with more observations does not draw a taller ridge for the same underlying shape- Built entirely on the existing
vidigi.analysis.event_durations- no new analysis function was needed, since a distribution is a reshaping of durations already, not a new statistic. Incomplete pairs (durationisNaN) are dropped before plotting - New-function style, per the plans for the rest of the 2.0.0/2.1.0 plotting work: no
interactive=, always returns a figure;**kwargsforwards tovidigi.analysis.event_durationsfor column-name overrides, not to a plotly call - there’s no single call to forward general styling to, sincegobuilds several traces by hand. Style the returned figure directly, or passtitle= - New
highlight_bands=(both the free function andTrialLogger.plot_duration_distribution), valid forkind="box"/"violin"only - see the newplot_metricbullet below for the shared implementation and dict shape
plot_metric_bar(bothvidigi.plots.plot_metric_barandTrialLogger.plot_metric_bar) gainsacross,error_bars,ci_levelandshow_runs, for putting an uncertainty interval on a bar chart for the first timeacross="entities"(the default, unchanged) pools every entity’s duration into one statistic per bar, exactly as every prior release didacross="runs"computes the chosen statistic separately within each run, then draws the mean of those per-run values - the number anerror_bars="ci"interval is actually abouterror_bars:"ci"(needsscipy),"sd","se", or the asymmetric"range"/"iqr", computed over the per-run values. Requiresacross="runs"- an interval over replication means attached to a bar pooled over entities would be internally inconsistent, since entities are correlated within a run and runs are the independent unitshow_runs=Trueoverlays each run’s individual value as a point on top of its bar; also requiresacross="runs"- Internally,
TrialLogger.plot_metric_baris now a thin delegator to the newvidigi.plots.plot_metric_bar, operating on the trial’s combined dataframe rather than callingget_event_duration_statper pair directly **kwargskeeps forwarding toplotly.express.barunchanged - the one function newly moved intovidigi.plotsthat is not switched to column-name passthrough, since the example notebook already relies ontitle=/width=reaching the chart- Deprecated later in 2.0.0 in favour of
plot_metric- see New features/Deprecations and the newplot_metricbullet below
- New
vidigi.plots.plot_metric(event_log, event_pair_list, kind=, ...)andTrialLogger.plot_metric(...)- thekind="bar"/"box"/"violin"replacement for the now-deprecatedplot_metric_bar, and vidigi’s first boxplot of per-replication summary values rather than raw per-entity durations (closes the “boxplots of metrics” part of #153 thatplot_duration_distribution’s existingkind="box"did not - that one only ever plots raw durations, optionally split by run/pathway, never a per-run statistic)kind="bar"(default) reproducesplot_metric_bar’s exact computation and output (checked directly against it, not just independently re-derived), rebuilt onplotly.graph_objectsinstead ofplotly.express- which is why this is a new function rather than akind=bolted ontoplot_metric_bar: that function’s**kwargsis documented, preserved plotly-passthrough (title=,width=), and ago-based box/violin mode would have quietly changed what**kwargsmeans wheneverkind != "bar"kind="box"/"violin"draw the full per-replication distribution for each event pair instead of collapsing it to a bar - one trace per pair - and requireacross="runs"(there is only ever one value per pair to draw a distribution from underacross="entities").error_barsis not valid with either (the box/violin already shows the spread);show_runs=Truesets the trace’s ownboxpoints="all"/points="all"instead of overlaying a separate scatter- New
highlight_bands=- shaded threshold zones behind the chart, valid with anykind. Each entry is a dict:lower/upper(float orNone- a missing bound extends to the plotted data’s range rather than needing an explicit sentinel; at least one must be given),colour(default"red"),label(defaultNone- adds a legend entry when given) andopacity(default0.12). Multiple bands stack (e.g. a green “target” zone plus a red “breach” zone in one call) - Built on a new shared private helper,
_add_highlight_bands-add_hrect/add_vrectplus a dashedadd_hline/add_vlineat each explicit bound only (never at a band’s data-derived open edge, which is padding, not a threshold), with the value axis range pinned explicitly afterwards so an open-ended band’s margin cannot itself blow out plotly’s autorange.add_hrect/add_vrecthave no native legend support, so a labelled band gets an invisible proxy marker trace purely for its legend swatch, rather than thefill="toself"polygon techniqueplot_replication_analysis‘s CI ribbon uses - that needs real coordinates spanning the shaded region, awkward against these charts’ categorical axis _add_highlight_bandsalways draws withrow="all", col="all", a no-op for every non-faceted figure (verified identical shape output either way) but what letshighlight_bandsalso work onplot_resource_utilisation_over_time/plot_queue_sizebelow, both of which facet into one subplot row per step/event when more than one is plotted -add_hrect/add_hlineotherwise only draw on the first subplot by default- No
**kwargsat all, unlikeplot_metric_bar- every column name (entity_col_nameand friends) is an explicit parameter instead. Style the returned figure directly withfig.update_layout(...)
- New
vidigi.plots.plot_resource_utilisation(event_log, by=..., metric=..., ...)andTrialLogger.plot_resource_utilisation(...), a bar chart of resource utilisation with one bar per group across runs- Thin wrapper over
resource_utilisation— that function already returns one row per run per group, so the bar height is just their mean and the error bar is built from the same per-run values, exactly asplot_metric_bar’sacross="runs"does for durations. The error-bar spread computation ("ci"/"sd"/"se"/"range"/"iqr") is shared withplot_metric_barvia an extracted helper rather than duplicated metric="utilisation"(the default) falls back to"mean_in_use", with a warning and a note in the title, if no capacity was resolved for any group —mean_in_useneeds no capacity at all, so this avoids drawing an all-NaNchart when the caller has not supplied oneerror_bars="ci"andshow_runs=Trueare the defaults here, unlikeplot_metric_bar— this function is new in 2.0.0, so has no pre-existing bare-bar behaviour to preserve- A dashed line is drawn at
y=1.0when the plotted metric is (or falls back to being)"utilisation", since a value above it is always diagnostic of a mis-resolved capacity or overlappingresource_useintervals by="run"draws a single bar labelled"All resources";sort_by="value"orders bars by descending value instead of by group- New
kind=/highlight_bands=, added later in 2.0.0 alongsideplot_metricabove -kind="box"/"violin"draws each group’s already-computed per-run values (run_arrays) as a distribution instead of a bar, reusing the shared_add_highlight_bandshelper (same dict shape). No deprecation dance needed here, unlikeplot_metric_bar- this function already buildsgo.Bardirectly with no**kwargsto conflict with
- Thin wrapper over
- New
vidigi.plots.plot_resource_utilisation_over_time(event_log, as_proportion=..., ...)andTrialLogger.plot_resource_utilisation_over_time(...), the resource equivalent ofplot_queue_size- Thin wrapper over
resource_occupancy_over_time. Per-run traces atopacity=0.2with a bold mean on top, faceted by step when more than one is present — the same visual language asplot_queue_size - Traces use
line_shape="hv", since occupancy is a step function — linear interpolation between snapshots would draw fractional resource counts that never existed as_proportion=Truedivides each step’s count by its resolved capacity. Unlikeplot_resource_utilisation, there is nomean_in_use-style fallback here: a partially-NaNproportion trace is more misleading than an error, so a step with no resolvable capacity raises naming it- New
highlight_bands=, added later in 2.0.0 alongsideplot_metric(see above) - spans every facet when more than one step is plotted, e.g. a shared “over capacity” zone across every resource
- Thin wrapper over
- New
vidigi.plots.plot_warm_up_diagnostic(...)/TrialLogger.plot_warm_up_diagnostic(...), built onwelch_moving_average(see New metrics) to visualise where to setwarm_up=- Deliberately no automatic warm-up-length selector: Welch’s procedure is explicitly a visual one, and a flatness threshold returns a confident number that is wrong on any series with slow drift, silently discarding the wrong amount of data with no signal anything went awry.
plot_warm_up_diagnosticoverlays severalwindows=values (default(5, 10, 20)) so the point where they agree can be read by eye plot_warm_up_diagnostic’sseries=selects what’s diagnosed:"queue"(viaqueue_size_over_time, needsevent=),"occupancy"(viaresource_occupancy_over_time, needsevent=), or"duration"(viaevent_durations, needsfirst_event=/second_event=, plotted against arrival order rather than a time axis, since a per-entity duration series has no snapshot grid)- New-function style, matching the rest of 2.0.0’s plotting additions: no
interactive=, always returns a figure;**kwargs/**col_kwargsforwards column-name overrides to whichever underlyingvidigi.analysisfunctionseriesselects, not to a plotly call - New
show_runs=onplot_warm_up_diagnostic/TrialLogger.plot_warm_up_diagnostic(defaultFalse), overlaying every individual replication’s own raw series atopacity=0.2- the fuller picture the DES RAP book’s own figures show alongside its pooled line. Unlikeplot_queue_size/plot_resource_utilisation_over_time’s equivalent (show_all_runs), every run shares one legend entry (“individual runs”) rather than getting its own - with a realistic replication count, a full per-run legend here would swamp thewindows=/methodentries that are the actual point of this plot. Each run is drawn at its own full length, not truncated to the shortest run the way the summary trace(s) are - most visible forseries="duration", where replications routinely complete different numbers of pairings
- Deliberately no automatic warm-up-length selector: Welch’s procedure is explicitly a visual one, and a flatness threshold returns a confident number that is wrong on any series with slow drift, silently discarding the wrong amount of data with no signal anything went awry.
- New
vidigi.plots.plot_replication_analysis(...)andTrialLogger.plot_replication_analysis(...), visualisingreplication_precision’s recommendation — the counterpart toplot_warm_up_diagnosticplot_replication_analysisdraws the cumulative mean and its CI band, plus (show_deviation=True, the default) a second stacked panel underneath showingdeviationagainst a dashed reference line atdeviation_threshold. The figure title reports the recommended replication count, or states plainly that deviation never converged within the replications available- New
marker_size=/line_width=(defaulting to the previous hard-coded 6/3, a verified no-op) on the cumulative-mean and deviation traces — the fixed default marker size overlaps into a solid, unreadable smear once a trial runs into the hundreds of replications, found while extending the example notebook to demonstrate an actually-converging case
- New
vidigi.plots.plot_outlier_runs(event_log, first_event, second_event, ...)andTrialLogger.plot_outlier_runs(...), visualisingflag_outlier_runs— a horizontal beeswarm of each run’s per-replication value, coloured (and shaped, for a colourblind-safe second cue) by whether Tukey’s fence flags it, with a shaded red band and dashed boundary line marking the region beyond each fence- The beeswarm layout is a small new private helper,
_beeswarm_offsets— a greedy row-placement algorithm (closest-to-centre row first) that keeps any two points withinspacingof each other off the same row, rather than a true pixel-aware packing (this function has no way to know the rendered figure size);spacingdefaults to a twentieth of the value range and is overridable
- The beeswarm layout is a small new private helper,
- New
vidigi.plots.plot_scenario_comparison(event_log_a, event_log_b, first_event, second_event, ...)/TrialLogger.plot_event_duration_comparison(other, ...)and their resource-utilisation twin,vidigi.plots.plot_resource_utilisation_comparison(event_log_a, event_log_b, ...)/TrialLogger.plot_resource_utilisation_comparison(other, ...)— the “highlight differences” chart for two scenarios, see New metrics for the underlyingcompare_replication_values/compare_event_duration_stat/compare_resource_utilisation- Two bars (one per scenario) with CI error bars, and a title stating whether the intervals overlap and the Welch’s-t p-value, worded to avoid overclaiming — “CIs overlap - not conclusively different”, never “no difference”. Both share a small private
_comparison_bar_figurehelper for this, so the two cannot drift plot_resource_utilisation_comparisontakesscenario_a=/scenario_b=(distinct fromlabel_a=/label_b=) for independent capacity resolution per scenario;TrialLogger.plot_resource_utilisation_comparisondefaults both from each trial’s own attached.scenario, exactly asget_resource_utilisationalready does- The counterpart to
plot_replication_analysis: that plot tracks one scenario’s stability within itself as replications accumulate, these compare between two scenarios - New
highlight_bands=on both, added later in 2.0.0 alongsideplot_metric(see above) - value range for the margin/open-bound computation is each bar’s mean plus/minus its CI half-width, so a band sized to just clip a CI edge renders sensibly rather than clipping the error bar itself
- Two bars (one per scenario) with CI error bars, and a title stating whether the intervals overlap and the Welch’s-t p-value, worded to avoid overclaiming — “CIs overlap - not conclusively different”, never “no difference”. Both share a small private
- New
vidigi.plots.plot_metric_vs_arrival_time(event_log, first_event, second_event, arrival_event=, colour_by=, rolling_window=, rolling_time=, warm_up=, marker_size=, line_width=, ...)andTrialLogger.plot_metric_vs_arrival_time(...)— see New metrics for the underlyingentity_metric_by_arrivalcolour_by="run"|"pathway"|Nonegroups the scatter, matchingplot_duration_distribution’s existingsplit_byrolling_window/rolling_timeare mutually exclusive smoothing strategies drawn over the (irregularly-spaced) scatter as a single bold trend line — a count-based and a genuine time-window moving average respectively, both symmetric and shrinking at both edges rather than dropping points there (unlikewelch_moving_average’s Welch method, every entity needs to stay visible on this chart). Whencolour_byis also set, the trend line is still one line pooled over every group, not one per group — matching the existing “faint per-group traces + bold pooled mean” visual language already used byplot_resource_utilisation_over_time/plot_queue_sizewarm_upexcludes points byarrival_time— not byfirst_time, unlike every otherwarm_upin this codebase — since this chart’s x-axis is arrival time; filtering byfirst_timeinstead could draw excluded points to the left of the stated cutoff wheneverarrival_event != first_event. Applied before any rolling average, so excluded points cannot leak into the smoothing near the boundary. The default of0is a verified no-op- New
highlight_bands=, added later in 2.0.0 alongsideplot_metric(see above) - an SLA-style zone read directly off the scatter, e.g. an acceptable-wait band against arrival time
- New
return_fig=onEventLogger.plot_entity_timeline, the last of vidigi’s plotting functions that only ever displayed a chart and returnedNonereturn_fig=False(the default, unchanged) still callsfig.show()and returnsNone, exactly as before.return_fig=Truereturns the figure instead, without callingfig.show(), for further styling or export (e.g.fig.write_image(...))- The default will flip to
Trueat vidigi 3.0, at which point the method stops callingfig.show()itself — noted in the docstring now so existing script callers relying on the display side-effect are not broken without warning
New examples
- New
examples/v2_release_additions/v2_release_additions.ipynb, a tour of everything above:event_durations/get_event_durations, resource utilisation (resource_use_intervals,resource_occupancy_over_time,resource_utilisation’s four capacity-resolution routes,plot_resource_utilisation/plot_resource_utilisation_over_time),plot_entity_timeline’s newreturn_fig=,VidigiStore(..., logger=...)auto-logging, and thecount/num_resources/n_waiting/.capacity/strict_capacityresource-pool inspection additions - none of which had a working code example anywhere in the repo before this.plot_duration_distribution,plot_metric’sacross=/error_bars=, the warm-up/replication-count/metric-vs-arrival-time diagnostics, andentity_icon_font/entity_colour_by/resource_iconget a short, real demonstration with a link to their existing dedicated notebook (feat_trial_logger.ipynb,feat_warm_up.ipynb,feat_replication_analysis.ipynb,feat_metric_vs_arrival_time.ipynb,feat_custom_icons.ipynbrespectively) rather than being re-explained from scratch - here, colouring patients by which of the four treatment cubicles (resource_id) is treating them, the same physical unitsresource_utilisationmeasured earlier in the same notebook. That section also points toentity_annotation_by(added afterwards) infeat_custom_icons.ipynb, rather than growing its own worked example- Reuses the same single-resource clinic model as the warm-up/replication/arrival-time notebooks, so every number is directly comparable - including an independently-derived ~78% cubicle utilisation matching the figure
feat_warm_up.ipynbalready quotes - Demonstrates that
VidigiStore(..., logger=...)auto-logging and manuallog_resource_use_start/log_resource_use_endcalls produce byte-identicalresource_use_intervalsoutput for the same model and seed, oncelimit_duration=is pinned explicitly on both sides -resource_use_intervalsresolves its analysis window’s end from the latest time in whichever log it’s given, which differs between a single run’s own log and a multi-run trial’s combined log - The
.count/.n_waitingsection runs a monitoring process alongside run 1 sampling both live, the way one would samplesimpy.Resource.count, and shows the mean occupancy (~3.07 of 4) landing on the same ~77% the log-derived utilisation section reports; it also demonstrates the two 2.0.0 breaking changes hands-on -.capacityreturning the pool size, and an over-capacityput()raisingValueErrorunlessstrict_capacity=False- and ties the untracked-poolRuntimeErrorto thepopulate_store()deprecation - Also demonstrates
plot_outlier_runsandplot_resource_utilisation_comparisonas the picture counterparts toget_outlier_runs’s table andcompare_resource_utilisation’s numbers above them - none of the 20 base-scenario runs flagged as an outlier (fence -0.54 to 17), and the 4-vs-3-cubicle comparison’s 78%-vs-97% utilisation gap rendered as two bars with clearly non-overlapping CIs - The
plot_metric_barsection is rebuilt onplot_metric(the deprecation’s replacement), and gains a new subsection forkind="box"andhighlight_bands- the per-run treatment-wait boxplot against a green “target” (<6 min) and red “concern” (>=12 min) threshold, with the real result (5 runs in target, 2 past the concern line, including the same run 10 that sat closest to the outlier fence earlier in the notebook) rather than an illustrative-only figure
- Reuses the same single-resource clinic model as the warm-up/replication/arrival-time notebooks, so every number is directly comparable - including an independently-derived ~78% cubicle utilisation matching the figure
vidigi.analysisandvidigi.plotsgain their own_quarto.ymlreference sections (Analysis Functions, Analysis Plots) - all 18 functions added across 2.0.0 previously had no API reference page at all, despite being documented in every docstring; onlyTrialLogger’s delegating methods were reachable via the site- New
examples/feat_synchronised_traces/, a feature breakdown for the newadd_subplot_panels/add_synchronised_trace/add_synchronised_trace_from_dataframehelpers - a static reference line, a per-frame bar panel and a cumulative line panel added to one animation and kept in step with the timelineexample_15_gas_station_refuellingis rebuilt on the helpers: its fuel-tank bar panel had regressed to showing only the first frame, and now animates across all of themexample_13_additional_synchronised_traces_method_1keeps its by-hand walkthrough (it is deliberately “method 1”) but gains a callout pointing at the helpers as the easier route
- New
examples/feat_animation_warm_up/feat_animation_warm_up.ipynb, demonstratingreshape_for_animations/animate_activity_log’swarm_up=/snapshot_alignment=- a top-of-changelog 2.0.0 feature with no worked example anywhere in the repo before this, despite every otherwarm_up=(thevidigi.analysisone,generate_dfg’s) already having one- Shows the naive-filtering trap directly: filtering the event log by time before reshaping triggers the new “entities with no arrival event” warning and drops every one of the patients genuinely still present at the cutoff from the animation entirely (0 of 7, in the notebook’s own run), where
warm_up=on the unfiltered log keeps all of them, present in the very first frame - Also demonstrates
snapshot_alignment="warm_up"vs"run_start"producing different first-frame times whenwarm_upisn’t a multiple ofevery_x_time_units
- Shows the naive-filtering trap directly: filtering the event log by time before reshaping triggers the new “entities with no arrival event” warning and drops every one of the patients genuinely still present at the cutoff from the animation entirely (0 of 7, in the notebook’s own run), where
examples/feat_process_maps/process_maps.ipynbgains a short demonstration ofgenerate_dfg‘swarm_up=, with real before/after node counts (arrivaldrops from 132 to 113 once the first 100 time units are excluded) and a note on how thiswarm_up=differs from the animation functions’ - a plain time-based row filter, not presence-aware trimming, which is fine here sincediscover_dfgbuilds each case’s edges from its own consecutive rows rather than reconstructing presence from arrival/departure rows the way the animation functions do- New Customising Animations page in the site navbar (
vidigi_docs/customising_animations.qmd), a single reference for every appearance argument toanimate_activity_log/generate_animationgrouped by what it changes (background colour and image, stage labels, icons, spacing and wrapping, playback, crowded-step gauges, setup mode), plus the “the figure is just Plotly, edit it directly” escape hatch. These were only discoverable by reading the full parameter list in the docstrings before - New Migrating to 2.0.0 page in the site navbar (
vidigi_docs/migrating_to_2_0_0.qmd), an action-oriented checklist of this release’s breaking changes and deprecations with before/after code, separate from the full changelog
Fixes
- Documentation deployments now invalidate cached page executions when Python models or CSV data under
examples/change, preventing stale notebook results while retaining cache reuse for text-only edits (#231). - BREAKING: Multi-replication event logs are rejected rather than silently blended
- Passing an event log containing several runs never raised and never warned — it produced an animation representing no run of your model.
reshape_for_animationspivots the arrival and departure rows to work out when each entity was present, and that pivot averages duplicates: an entity arriving at t=1 in run 1 and t=41 in run 2 was given an arrival of 21 and a departure of 71. A latergroupby(...).tail(1)then discarded one run’s rows entirely - Every downstream check still passed, because the resulting frame is internally consistent and completely fictional
reshape_for_animations,generate_animation_df,generate_animationandanimate_activity_lognow all raise aValueErrornaming the offending column and showing how to filter- Two independent checks, because neither alone suffices. A run column carrying more than one value is caught even when entity IDs are unique across runs; an entity with more than one
arrivalordepartis caught even when the run column is named something unexpected or is absent entirely. The second also catches entity IDs reused within a single run, which corrupts an animation in exactly the same way - New
run_col_nameargument on all four functions. Defaults to"auto", which looks for a column named (case-insensitively)run,run_number,replication,reporrun_id. Pass an explicit name to override, orNoneto disable the check - Chosen over a deprecation period deliberately: warning first would mean another release cycle of users presenting wrong animations to stakeholders, and the only behaviour being removed is “silently produce a wrong answer”. The constraint was previously documented only in a tutorial page and in a source comment sitting above code that did not enforce it
- Passing an event log containing several runs never raised and never warned — it produced an animation representing no run of your model.
- BREAKING:
reshape_for_animationsnow writes the exit step’s event type to the column named byevent_type_col_name- Previously it always assigned to a literal
"event_type"column. A log using a custom event type column therefore came out with two type columns: the caller’s, left empty on every exit row, and a spuriousevent_typecontaining nothing but"exit" generate_animation_dffilters on the caller’s column, so exit steps were not being recognised as exitsanimate_activity_lognow also forwardsevent_type_col_nametogenerate_animation, which builds the queue-position hover text by testing this column for"queue". A custom event type column reached the reshape and positioning steps but not this one, so the call died withKeyError: 'event_type'- If you use the default column names, nothing changes
- Previously it always assigned to a literal
backendnow matches case-insensitively for every spelling- The plotly express branch lowercased its input and the graph objects branch did not, so
backend="EXPRESS"was accepted whilebackend="GO"was rejected as invalid - The error message also listed only two of the four graph objects spellings, so
"plotly graph objects"and"plotly go"worked but were never advertised
- The plotly express branch lowercased its input and the graph objects branch did not, so
reshape_for_animationsno longer fails on an event log in which no entity has departed- A truncated run, a warm-up period, or a model whose entities never leave produces no
departevents, so the pivoted log had nodepartcolumn. Every snapshot was silently emptied and the function then failed with an opaqueKeyError: 'entity_id' - A missing
departcolumn is now read as “everyone is still in the system”, which is what an absent departure means - A log with no
arrivalevents now raises aValueErrornaming the arguments to check, rather than failing later with an unrelated error
- A truncated run, a warm-up period, or a model whose entities never leave produces no
limit_duration=Nonenow behaves as the docstring describes inreshape_for_animations- The integer coercion applied to the argument rejected
Nonebefore the function’s own handling could run, and that handling would itself have failed by reading a column consumed by the pivot - It now resolves to the largest time in the event log, matching how
animate_activity_logalready computed the same default
- The integer coercion applied to the argument rejected
wrap_queues_at=Nonenow behaves as documented ingenerate_animation_df- Two sites used the value arithmetically before the existing
Nonebranch was reached: thestep_snapshot_maxmultiple check, and the overflow label offset inside annp.where(which evaluates both branches, so the condition could not short-circuit the division)
- Two sites used the value arithmetically before the existing
animate_activity_lognow respectstime_col_namewhen working out a defaultlimit_duration- It previously read a literal
"time"column, so every caller with a custom time column hitKeyError: 'time'
- It previously read a literal
hover_text_entity=Nonenow disables hover as documented- The underlying plotly express call does not accept the
hoverinfoargument that was being passed, so this option raisedTypeErrorrather than doing anything
- The underlying plotly express call does not accept the
- Passing a
scenariofor a model where no event position declares a resource no longer fails- This produced
KeyError: 'x_final', which read like a problem with the caller’s data rather than a missing guard
- This produced
- A minimal
animate_activity_log(event_log=..., event_position_df=...)call no longer clips its auto-generated stage labels or edge-of-layout icons- With no
override_x_max/override_y_maxthe axis range was derived purely from event anchor points, leaving only0.25 * x.max()of space to the right of the last anchor — not enough for a label like"Being Seen By Nurse", which was chopped at the axis. Queue and resource icons drawn left of a low-x anchor (including thewrap_queues_atoffset) were clipped atx = 0the same way - The figure margin now expands to fit the longest label and the furthest icon, and
cliponaxisis disabled on the content traces so they render into it. The data ranges are untouched, so node spacing,override_x_maxalignment and background images are unchanged; margins only ever grow, so an animation whose labels already fit is identical override_x_max/override_y_maxremain the escape hatch for a layout the auto-sizing gets wrong
- With no
custom_hover_datais no longer modified in place- The list passed in was appended to directly, so it grew by an entry on every call and eventually referenced the same column twice
- The resource column is now only offered when the event log actually contains one
- BREAKING:
custom_hover_datanow requires a matchinghover_text_entity. Callers who never passedcustom_hover_data, or who already paired it with their ownhover_text_entity(as the docstrings recommend and every example does), see no change — only the broken combination now raises instead of returning a figure with garbled hover- The default hover template indexes
customdata[0..5]by fixed position (entity id, time, snapshot time, label, time in event, queue position). Passingcustom_hover_datareplaces that list wholesale, so the default template then read the wrong columns — or ran off the end of a shorter list — and rendered broken hover with no error - Supplying
custom_hover_datawhile leavinghover_text_entityat its default now raisesValueError, which names the six default columns so a caller who wanted those plus extras can rebuild the template
- The default hover template indexes
- Invalid
backendandtime_display_unitsvalues now raiseValueErrorcarrying the intended guidance- Both were raised as bare strings, which Python rejects with
TypeError: exceptions must derive from BaseException, so the message explaining the valid options never reached the user - Found by CI running on Linux/macOS as well as Windows: an unrecognised custom
time_display_unitsstrftime directive (e.g. a typo’d%Q) was detected by lettingdatetime.strftimeitself raise, but that validation is delegated to the platform’s C library and isn’t portable - it raises on Windows (msvcrt) but glibc/macOS libc silently pass an unknown directive through unrendered instead, so theValueErrornever fired there and a typo produced a silently wrong frame label rather than a clear error.time_display_unitsis now checked against a portable whitelist of valid strftime directives before ever callingstrftime, so an invalid custom format is rejected identically on every platform. A valid custom format (e.g.'%Y-%m-%d %H') is unaffected
- Both were raised as bare strings, which Python rejects with
- An unrecognised
simulation_time_unitnow raisesValueErrorlisting the valid units, instead ofUnboundLocalError - BREAKING:
TrialLoggerstatistics now reflect logs added after construction- The combined trial dataframe was built once in
__init__and never rebuilt, so a run added withadd_logwas counted bysummary()while being absent from every statistic computed from that frame - A trial assembled by constructing empty and adding runs in a loop — a natural way to write it — produced statistics for no runs at all
- The frame is now derived from the current set of logs on each access, so it cannot go stale
- Any figure you have previously reported from a trial built this way was computed from a subset of your runs and will change
- The combined trial dataframe was built once in
- BREAKING:
get_event_duration_stat(what="summary")reports the number of unserved entities underunserved_count- It returned
series.size, the total number of entities, so a trial where everyone was served still reported every entity as unserved.unserved_count_mean_per_runcarried the same error - The standalone
what="unserved_count"path was already correct, so the two routes to the same statistic disagreed
- It returned
- BREAKING:
TrialLogger.plot_queue_sizereports the queue length that actually formed- Three separate errors, each of which made a queue look better than it was, and none of which produced any visual cue that something had been discarded
- Long queues saturated. The chart was built by reshaping the log with the default
step_snapshot_max=60, which caps how many entity icons an animation draws. With the cap applied to a line chart, a queue of 150 plotted as a flat 61 — a growing bottleneck reading as a stable queue. The cap is no longer applied here, since a line has no drawing limit - Empty queues went missing. A snapshot with nobody queuing produced no row to count, so no point was plotted and the line was drawn straight across the gap — asserting a queue over precisely the interval it had emptied. Genuine zeros are now plotted
- The mean was biased upwards. It averaged only the runs that had somebody waiting at that moment, so two runs holding 1 and 0 gave a mean of 1.0 rather than 0.5. Every run now contributes at every snapshot
- An event named in
event_listthat occurs in no run is plotted as zero throughout and now warns, since zero-filling would otherwise make a misspelt event name indistinguishable from a queue that never formed - Reshaping without the cap uses more memory than an equivalent animation
TrialLogger()can be constructed with no arguments- This raised
ValueError: No objects to concatenate, which ruled out creating an empty trial and filling it withadd_log
- This raised
TrialLogger.get_log_by_run(run, as_df=True)returns a DataFrame- Both branches of the
as_dfcheck returned the same thing, so the parameter did nothing
- Both branches of the
TrialLoggernow rejects anEventLoggerwith no events or norun_number- The run id is read from the first event, so these previously failed with
IndexErroror storedNoneas the run id, making the log unretrievable by run
- The run id is read from the first event, so these previously failed with
EventLogger.from_csvnow leaves the logger in a usable state- It assigned the DataFrame directly to the internal log, which is a list of records everywhere else. Afterwards
get_events_by_entityand friends walked column names and failed withAttributeError,to_json/to_json_stringfailed withTypeError, andto_csvfailed on an ambiguous truth value - Only
to_dataframeandsummaryhappened to work, so the breakage was easy to miss
- It assigned the DataFrame directly to the internal log, which is a list of records everywhere else. Afterwards
- The “resource_id is recommended” warning now actually fires
- It was defined as a validator on a field with a default, and pydantic skips those when the caller omits the field — precisely the case the check exists to catch. It only ever fired when a resource id was supplied but had the wrong type
- Logging a
resource_useorresource_use_endevent with noresource_idnow warns, as documented
- Removed a stray debug
printfromEventLogger.plot_entity_timeline, which dumped the entity’s events to stdout on every call VidigiStore.cancel_getnow works- It looked for the pending-request queue on itself, but
VidigiStorewraps asimpy.Storerather than subclassing it. Every call raisedAttributeError, which the method’sexcept ValueErrordid not catch, so cancelling a request — and therefore modelling reneging with this class — was impossible VidigiPriorityStorewas unaffected, as it keepsget_queueas its own attribute
- It looked for the pending-request queue on itself, but
VidigiStore/VidigiPriorityStorenow flag arequest()context manager that is never awaitedwith store.request(...) as req: resource = yield reqis the required pattern. Omitting theas req: yield req— entering thewithblock and going straight toyield env.timeout(...)— meant the entity never actually waited for or held the resource: its timeout ran immediately, it logged its departure, and the still-pending request was then granted to it after it had moved on, logging a phantomresource_usewith no matching end and consuming a unit that was never released- In an animation this showed as the entity skipping from the queue straight to the exit, because presence at each snapshot is derived from the arrival and departure rows and the departure now preceded the resource-use row. It regressed the
feat_synchronised_tracesexample - Exiting the
withblock with the request unprocessed — impossible under correct use — now emits aUserWarningnaming the fix (unless an exception is already propagating), detaches the pending start-of-use log callback, and releases the abandoned request (returning the unit if one was already in hand, otherwise dropping the queued get) - For a model that was already misusing the pattern this changes the event log: the phantom post-departure
resource_userows disappear and the unit is no longer leaked. Only code that was already producing a broken animation is affected, so this is a correction rather than a**BREAKING:**change - Not caught: a lone entity with a free unit whose spurious
env.timeoutoutlasts the immediate grant, since by its block exit the request has been processed — that case does not produce the visible “skips to the exit” symptom
- BREAKING: The default resource marker now honours
resource_icon_size- With no
custom_resource_iconand no per-eventEventPosition.resource_iconoverride, vidigi draws a plain dot for each resource - the fallback every animation without a custom icon uses. Itsgo.Scattermarkersizewas hardcoded to15, soresource_icon_sizeonly took effect once a custom icon (image or glyph) was supplied, exactly as the issue reporter found: “works if custom icon passed” - The image-icon path (
add_layout_image’ssizex/sizey) and the glyph-icon path (textfont.size) already treatedresource_icon_sizeas a literal pixel value; the default dot’s markersizenow matches both resource_icon_sizedefaults to24, so an animation that never set it will see its default resource dot grow from 15px to 24px. Passresource_icon_size=15to keep the old size. Closes #120
- With no
entity_colour_byno longer crashes on pandas >=3.0 when colouring by a numeric column (e.g.resource_id) that is genuinely missing for some entities (not currently holding a resource)- Under pandas <3.0, converting that column with
.astype(str)turned a missing value into the literal string"nan". Pandas 3.0’s new defaultstrdtype instead leaves it as a rawfloat('nan'), which then crashedsorted()comparing it against every other category’sstr - Fixed by converting with
.map(str)instead, which calls Python’s ownstr()per value rather than going throughastype’s pandas-3-specific special-casing, giving the same"nan"string on every supported pandas version
- Under pandas <3.0, converting that column with
Deprecations
minimize_output_dfis deprecated and remains inert- It has never had any effect: the loop meant to implement it discarded the result of
.drop(), so the documented default ofTruewas always a no-op - Making it work now would change the output of every existing caller, including removing the
runcolumn, so the behaviour is deferred to 3.0 - Passing it emits a
DeprecationWarning; callers who never passed it are unaffected
- It has never had any effect: the loop meant to implement it discarded the result of
populate_store()is deprecated and will be removed in vidigi 3.0- It predates the
num_resources=constructor argument and.populate()method onVidigiStore/VidigiPriorityStore, which now cover the same job. Build the pool withVidigiStore(env, num_resources=N, label=...)(orstore.populate(N, label=...)to top one up); a plainsimpy.Storefilled withpopulate_store()should become aVidigiStore, a drop-in replacement forsimpy.Resource - Calling it emits a
DeprecationWarningregardless oflabel(the whole function is going, not just the unlabelled path). It still works unchanged until 3.0 populate_store()does not feed the newcount/num_resourcesproperties — another reason to populate through the store- Every bundled example that used
populate_store()is migrated toVidigiStore(num_resources=…)/VidigiPriorityStore(num_resources=…)(verified byte-identical event logs).example_3now demonstrates the rewrittenVidigiPriorityStorewith the directget(priority=…)/put()pattern rather thanVidigiPriorityStoreLegacy
- It predates the
plot_metric_bar()/TrialLogger.plot_metric_barare deprecated and will be removed in vidigi 3.0- Replaced by
plot_metric(kind="bar", ...)/TrialLogger.plot_metric(kind="bar", ...)(see New plots) - the same computation, rebuilt onplotly.graph_objectsso it can also offerkind="box"/"violin"andhighlight_bandswithout changing whatplot_metric_bar’s own**kwargsmeans to existing callers - Calling either emits a
DeprecationWarning. Both still work completely unchanged until 3.0 - no behaviour, output or**kwargsmeaning changes
- Replaced by
Testing
Test coverage grew from 31 to 1346 tests, concentrated on the parts of the pipeline where a mistake changes what the animation shows, or what the reported numbers say, rather than raising an error.
- Backend aliases must return complete animation timelines; resource comparisons use different scenario capacities; and Welch’s test is checked against an independently calculated p-value for unequal sample sizes and variances. These assertions catch failures that previously passed unnoticed.
- CI now runs tox across supported Python versions and both Plotly major versions. Minimum-dependency jobs run the core suite, while the model-equivalence tests run in environments with pandas 2 and a compatible sim-tools release; tox measures vidigi coverage and checks installed dependencies.
- Icon fonts no longer raise on Plotly 5, whose trace font schema lacks
weight. Their font family is still selected there; Plotly 6 retains the explicit weight setting. reshape_for_animationsis now asserted by value rather than by shape: which entities are present at each snapshot, which event each is shown at, queue ordering, exit step timing, and thestep_snapshot_maxcapgenerate_animation_dfgained its first dedicated coverage: entity and resource positions, queue wrapping, icon assignment, and the overflow placeholder- The new unpositioned-rendered-event warning is covered by value, not just by trigger/no-trigger: an event always superseded by its successor (mutation-proven not to warn for the wrong reason - a naive check that skipped the “was it actually rendered” filter did warn, and was reverted), the row/entity counts and event names in the message for a genuinely rendered gap, multiple gaps collapsing into one warning, a hand-built (not
create_event_position_df-built) frame, and thestep_snapshot_maxoverflow row not double-counting animation.pygained its first dedicated coverage: frame count and ordering, animation timings, hover configuration, resource markers, every time display format, background image embedding, and the error paths- The auto-layout margin fix is covered by value:
cliponaxis=Falsereaching every content trace and every frame trace, the right margin growing only when a stage label overflows the last anchor (and not at all when labels are hidden), the left margin engaging only when queue icons crossx = 0, and a plain animation leaving both margins and the data range untouched — the margin computations each mutation-proven EventLoggergained its first dedicated coverage: the event shape each helper produces, time taken from both simpy-style and salabim-style environments, event validation and its warnings, timestamp parsing, retrieval, and exportTrialLoggergained its first dedicated coverage: construction, that statistics stay current as runs are added, and every duration statistic checked against hand-computed values including the served/unserved accounting- The single-replication guard is covered across all four animation entry points, including column-name detection, both independent checks, and — most importantly — that a valid single-run log carrying a run column is still accepted
- The new
EventLogger/TrialLoggeranimate_activity_log/reshape_for_animationsmethods are pinned by whole-frame equality against the module-function form (mutation-proven: a delegator that swallows**kwargsdiverges), plusrun_numberforwarding and the multi-run rejection onTrialLogger - Warm-up handling is covered end to end: that
warm_upshows the entities a truncated log loses, that the truncation trap itself is detected, that both snapshot alignments move frame times without changing who is in them, and that the defaults are a true no-op rather than merely a similar result - Every value advertised by a literal-typed argument is asserted to be accepted at runtime, so the annotations cannot drift from the checks they describe
process_mappinggained its first dedicated coverage: the newwarm_upfilter, and what it does and does not affect for a case that spans the cutoff versus one entirely inside it- Run-aware DFG discovery is pinned:
discover_dfg’s single-run output is byte-for-byte unchanged withrun_col_name=set (all five time units), the fabricated cross-run edge is present without run grouping and gone with it (mutation-proven against dropping the run key), the multi-run warning fires only when it should, and theTrialLogger.generate_dfgroutes are covered — the mutually-exclusiverun_number/across_runsguard, representative-run selection (mutation-proven that it uses the median, not the mean), and the cross-run node/edge tables hand-computed whole-frame (per-replication counts, between-run ranges with zero-fill, pooled and per-run mean times, probabilities summing to 1) including theacross_runs="average"occupancy threading and the missing-warm_upwarning plot_queue_sizeis now asserted against hand-computed queue lengths rather than only checking that a figure came back — the previous tests would have passed against a blank chart, and did pass while every long queue was saturatingcancel_getis now covered for both store types, including an end-to-end reneging scenario asserting who is served and when- Two invariants the source had flagged as unchecked are now enforced — no entity is drawn in two positions within a single frame, and each entity keeps the same icon throughout
vidigi.analysis.event_durationsis covered against hand-computed durations, including a rework-loop fixture the oldpivot-based calculation cannot even run against, every pairing mode, the outer-join edge cases (started-but-unfinished and finished-but-unstarted), and the missing-run/missing-pathway-column fallbacksTrialLogger.get_event_duration_stat, the newget_event_durations, andvidigi.analysis.event_durationsdirectly are all pinned against the oldpivot-based calculation on every applicable existing fixture — including one with a run column spelledrunrather thanrun_number, to checkrun_col_name="auto"against the same reference — so the rebuild is proven byte-for-byte equivalent wherever the pivot used to work. A dedicated regression test covers the per-run denominator fix, proven to fail against the old formula before being restoredvidigi.analysis.queue_size_over_timeandvidigi.plots.plot_queue_sizeare covered directly as free functions, not just throughTrialLogger, including a warm-up window trim proven to fail if the argument were dropped,run_col_name="auto"detecting a plainruncolumn, a log with no run column at all, and that plotly express kwargs still reach the chart after the extractionbackend="go"onplot_queue_sizeis covered for hand-computed queue lengths (single and faceted), the empty-queue-as-zero and mean-across-runs behaviour matching the express backend, warm-up trimming, every accepted spelling and case-insensitive matching, the invalid-backend error, and that facet row placement is correct (proven to fail if a mutated row mapping put every event’s traces on the same row)plot_duration_distributionis covered for everykindagainst hand-computed (or independently numpy-computed) bin edges, counts, densities, raw values and ECDF step arrays;split_byis checked as a full{trace name: values}mapping for both"run"and"pathway", and proven to fail if the two columns were swapped; the ECDF’s step shape is proven to fail ifline_shape="hv"were dropped; incomplete pairs are confirmed dropped before plotting rather than erroring or appearing asNaN; and everyDistributionKind/SplitByliteral value is asserted to be accepted at runtime, matching the pattern already used forDurationStat"ridgeline"and"heatmap"are covered for the full per-group polygon/matrix against independently numpy-computed bin edges and densities/counts, that both requiresplit_by, and - proven to fail if the density were swapped for a raw count - that a group with more observations draws the same ridge height as one with fewer, given the same underlying shapereplication_meansandmean_confidence_intervalare covered against a purpose-built fixture (unequal_run_loggers) whose durations are not all equal, sostdand every CI half-width are non-zero andacross="entities"genuinely differs fromacross="runs"- every expected value (run means, pooled mean, standard error, half-width) is hand-computed against a published Student’s t table, never derived fromscipycalling itself. Two mutations are proven to fail their respective tests before being reverted: computing the interval over pooled per-entity durations instead of replication means, and swappingt.ppffor a normalz.ppf. A missingscipyis simulated (not actually uninstalled) to confirm theImportErrornamespip install vidigi[stats]plot_metric_baris covered for bothacrossmodes against the same hand-computed values, everyerror_barskind against independently computed spreads (including the asymmetric"range"/"iqr"), thatci_levelactually reaches the confidence-interval calculation rather than being silently ignored,show_runs’s overlay points, thaterror_bars/show_runsare rejected withoutacross="runs", the “no run has a complete pair” and single-replication (NaNhalf-width, with and without a warning) edge cases, and that**kwargsstill reachesplotly.express.barunchanged after the extraction intovidigi.plotsTrialLogger.get_event_duration_stat’s newacross=and the newget_event_duration_ciare covered against the sameunequal_run_loggersfigures:across="entities"pinned to the pooled 5.75 (a verified no-op for the default),across="runs"to the mean-of-run-means 6.0 with a mutation proving it is not pooling,what=reachingreplication_means, and the entity-counting-what/exclude_incomplete=False/ bad-across/ no-complete-pairs rejections.get_event_duration_ciis checked against the fixture’s hand-computed half-width (~6.5724, with a mutation proving it is not the ~30x-too-narrow pooled interval),ci_levelpassthrough,what=passthrough, the no-complete-pairs error and the single-replicationNaN-and-warn path- An independent adversarial review of the tests added for replication statistics and
plot_metric_barfound one tautological test (a quantile assertion whose fixture had no per-run variation forqto distinguish) and one silently-untested parameter (ci_level); both are now fixed and mutation-proven, alongside the two edge-case gaps above resource_use_intervalsis covered against a purpose-built fixture (resource_use_loggers) with a resource idle in one run but used in another, proving a genuine zero is reported rather than a missing row;unclosed="censor"vs"drop"are covered as a mutation-proof pair (busy time 5 vs zero rows), the orphan-end and missing/partially-null-resource_idpaths are each covered with their warning or error, and busy-time clipping is checked against awarm_uptrim_resolve_resource_capacitiesis covered for the full{step: capacity}dict from each of routes A-D (not sampled entries), that route A takes precedence when multiple routes are redundantly supplied, both error paths (scenariowith no route, and aresource_map/event_position_dfnaming a missing attribute — checked to include the attribute, the step and the available-attributes list), and the missing-capacity/unknown-step warningsresource_utilisationis covered for all threebymodes against the fixture’s hand-computed values, thatby="resource"’s capacity is always exactly1regardless of what capacity route is supplied, and thatby="run"agrees withby="step"when there is only one step, sums capacities (and warns) when more than one is pooled, and propagatesNaNif any pooled step’s own capacity is unresolved- The extraction of
vidigi.utils._resource_map_from_event_position_dfout ofanimation.pyis proven behaviour-preserving by the existing animation suite (84 tests) passing unchanged - Three independent expert reviews (Python code quality, DES/OR domain correctness, and test QA) of the resource-utilisation commit found two real bugs, now fixed and covered by a mutation-proven regression test each:
event_durationsandresource_use_intervalsboth paired rows viagroupby(...).cumcount()/.head(1)/.tail(1), which default todropna=True— aNaNin a pairing key (e.g. a malformedentity_id) either fabricated a cross-joined pairing (match="occurrence",resource_use_intervals) or silently dropped the entity from the output entirely (match="first"/"last"), with no error or warning either wayby="run"’s “do the pooled steps share one capacity” check readcapacitiesglobally across the whole trial rather than the run being aggregated, so two runs each using only one (different) resource type both came outNaNeven though each was individually well-defined — and, separately, two different resource types that happened to share the same per-unit capacity used that single value as the divisor for their combined busy time, silently producing utilisation over 100% instead of a coherent pooled figure. Both are fixed by the sum-based redesign described above- Also added along the way:
by="resource"no longer silently returns zero rows instead of a genuine zero whenresource_idis missing from the whole log;resource_use_intervals/resource_utilisationcorrectly pair an entity revisiting the sameresource_idtwice in one run (proven to fail if the pairing key that prevents this were dropped); a zero-length window (warm_up == limit_duration) now raises instead of silently dividing by zero;event_type_col_namenaming a missing column now raises a legibleValueErrorinstead of a bareKeyError; and thecapacity="infer"warning now names the common “resource_id doesn’t identify individual units” failure mode explicitly, not just “a never-used unit is invisible”
resource_occupancy_over_timeis covered against the same hand-computedresource_use_loggersfixture asresource_use_intervals, with the full per-run step-function array asserted (not sampled points): the half-open[start, end)convention is proven to fail if the snapshot lookup were changed from a right- to a left-biased search, unclosed resource use is confirmed occupied through to the window end rather than vanishing or spanning the whole window, and a log with no resource-use events at all returns an empty frame with the correct columns rather than raisingplot_resource_utilisationandplot_resource_utilisation_over_timeare covered as free functions against the same fixture: the bar-chart mean and CI half-width against the published Student’s t value forn=2, the full{resource_id: value}mapping forby="resource"(not sampled entries), theby="run"single-bar case, themetric="utilisation"→"mean_in_use"fallback and its warning (mutation-proven), they=1.0line appearing only for a resolvedutilisationmetric (mutation-proven),sort_by="value"reordering bars away from their natural (alphabetical) group order — chosen specifically so the two orders disagree, since a fixture where they happen to coincide would pass whether or not sorting actually ran — the full per-snapshot occupancy curve andline_shape="hv", andas_proportion’s division by capacity (mutation-proven) and its missing-capacity error.TrialLogger’s two delegating methods are each checked to reproduce the same hand-computed figuresby="resource"’s new overlap warning is covered against a purpose-built fixture of two entities genuinely overlapping on oneresource_id(mutation-proven to fail if the check were disabled), a fixture proving the exact half-open boundary — a bout starting exactly when the previous one ends must not warn (mutation-proven to fail if the comparison were<=instead of<) — and a fixture proving the grouping key is unchanged: one physical resource legitimately reused across two different step names, non-overlapping, still returns exactly one row rather than being split per step- The new opt-in
label=onVidigiStore,populate_store()andVidigiPriorityStoreis covered for a true no-op at the default (id_attributeunchanged, and neither.labelnor.unique_id_attributepresent at all — not merelyNone), that omitting it now emits aDeprecationWarningand that passing it suppresses that warning, the exactunique_id_attributevalue shape for a single pool, that two differently-labelled pools produce disjointunique_id_attributes whileid_attributeitself still (correctly) collides between them, and an end-to-end test provinglabel=actually fixes the motivating problem: the same two-pool collision that makesresource_utilisation(by="resource")warn on the defaultresource_idcolumn no longer does when pointed atunique_resource_idinstead - Found and fixed while adding the above:
resource_use_intervals’s column-renaming raisedValueError: The column label 'resource_id' is not uniquewheneverresource_col_name=(orentity_col_name=/run_col_name=) pointed at a column other than the canonical default while that default name was also present in the log — exactly the situation of logging bothresource_id(for animation) and a separate collision-proof ID (for analysis) side by side, which is the whole point of the newlabel=option. Mutation-proven regression test added - The
stacklevelfix is covered by constructing two unlabelled pools with an explicit"default"warning filter (not pytest’s own"always", which would mask the bug) and asserting both warn — mutation-proven to collapse to one warning if the fix were reverted — plus a direct check that the warning’sfilenameis the test file, notresources.py - The same-label collision check is covered for warning when two pools share a label on one
simpy.Environment(mutation-proven for all three pool-construction call sites), for not warning when the same label is reused across two different environments — the replication-safety case, mutation-proven to produce a false positive if the check were made global rather than per-environment — and for not warning between two differently-labelled pools on the same environment resource_col_name=None(auto-detect) is covered on all threeTrialLoggermethods: a fixture whereunique_resource_idis present and genuinely disagrees withresource_id(mutation-proven to fail if the auto-detection were removed), that an explicitresource_col_name=still overrides the default, that a log with nounique_resource_idcolumn falls back to exactly the pre-existing behaviour, and — forplot_resource_utilisation_over_time, which had noresource_col_nameat all before this — that the parameter genuinely reaches the underlying function (mutation-proven) via the fallback warning naming a deliberately-wrong column, plus that theNonedefault doesn’t spuriously trigger the same warning- The new
filter_fn=on both stores has its own file: thatfilter_fn=Noneis identical to omitting it, that a filtered request is granted a matching unit rather than the front of the pool, that it queues past non-matching units returned in the meantime and is released by the matching one,priorityandfilter_fncombining so a lower-priority matching waiter beats a higher-priority non-matching one, filtered reneging viacancel_get, the unawaited-request guard returning the matched unit, an unsatisfiable filter simply never granting, a non-callablefilter_fnraising at the call site, and auto-logging recording the filtered unit.VidigiPriorityStore’s three queue-walk sites (item scan inget(),_put_item,_return_item_raw) and the_put_itemcapacity reorder are each mutation-proven — reverting the walk topop(0)fails the named test - Found by an independent review:
_resolve_resource_col_namewas rebuilding the trial’s combined dataframe a second time (apd.concatover every run) purely to check column membership, on top of the caller’s own rebuild for the real call — doubling the cost of everyby="resource"call on the default path. Fixed by building the frame once per method and passing it into the resolver; covered by a test proving the resolver checks the dataframe it is given, not one it re-derives itself welch_moving_averageis checked against a hand-computed, deliberately non-linear three-run series (welch_series) for both methods, full array asserted at every index including the shrinking left edge — mutation-proven to fail if the edge case were removed and the interior formula applied throughout — plus the output-length contract for each method, unequal-length runs truncating with a warning, a single-run trial for both methods (its own ensemble mean, checked against non-linear values so the shrinking-edge formula is genuinely exercised, not just an averaged-out series), and every validation error (series_by_runempty, unknownmethod,windowmissing/non-positive/too large formethod="welch"), plusmethod="none"matching the ensemble mean exactly and ignoringwindow(mutation-proven).plot_warm_up_diagnosticis covered for all threeseries=options against hand-computed arrays reached through the full pipeline (event_log→vidigi.analysisfunction → ensemble mean → smoothing), theseries="duration"x-axis being arrival order rather than time,show_ensemble’s extra trace, one trace perwindows=entry, the nearest-match hint on an unknownseries="occupancy"event,series="queue"withlimit_duration=Noneresolving to the log’s latest time without the spurious int-coercion warning a raw.max()would trigger, every mutually-exclusive/missing-argument validation error, and a shape-only check (rises then flattens, never a specific warm-up value) against a purpose-built non-stationary fixture, and thatmethod="none"suppressesshow_ensemble’s reference line (mutation-proven, since it would otherwise draw the identical trace twice).show_runs=is checked against the same hand-computed per-run occupancy values (not sampled entries, mutation-proven against truncating every run’s trace to the shortest one), that every run shares one legend entry rather than getting its own (mutation-proven), and - viaunequal_run_loggers, whose three runs have different entity counts - that each run’s raw trace keeps its own full length rather than being cropped to match the shorter summary trace(s) (mutation-proven).TrialLogger.plot_warm_up_diagnostic’s delegation is checked against the same hand-computed figures, plus itsmethod="cumulative"/series="duration"/match=/show_runs=paths, none of which the single original delegation test reached- Two independent expert reviews (an OR/DES specialist and a Python QA engineer) of the Welch diagnostic found: the “cumulative” docstring wording fixed above; a coincidentally-passing
TrialLogger.plot_warm_up_diagnostictest whoselimit_duration=20happened to equal the fixture’s own natural log end, so it couldn’t have failed even with the passthrough dropped entirely — fixed to use a value that genuinely disagrees with the default, and mutation-proven; and, while re-deriving that test’s expected values independently as part of the fix, a hand-computation error of my own (missed that both runs’ bouts are clipped to end exactly at the new, shorter window boundary, which the half-open[start, end)convention then reads as unoccupied there) — caught before landing, not after, by verifying against a live run rather than trusting the arithmetic event_durations’s newwarm_upis covered for the exclusion-by-first_timerule (full entity-set assertion), the exact-boundary case (mutation-proven inclusive, not exclusive), a pairing with nofirst_timesurviving regardless (mutation-proven), the interaction withkeep_incomplete=False, filtering per pairing rather than per entity undermatch="occurrence"(using the existing rework-loop fixture), the default’s verified no-op, and the negative-warm_uperror.plot_metric_bar’s explicitwarm_up=andplot_duration_distribution’s passthrough (via**kwargs, needing no new parameter) are each covered with a fixture asymmetric enough that a droppedwarm_upwould give a different, not merely absent, answer — mutation-proven onplot_metric_bar’s threading specifically, after an initial version of that test used values that happened to coincide either way- The now-documented BREAKING edge case — a trial with
unique_resource_idon some resource-use rows but not others raising under the new default where the old hard-coded"resource_id"default used to succeed — is pinned by a regression test, including thatresource_col_name="resource_id"is a working escape hatch back to the old behaviour replication_precisionis checked against the same hand-computedunequal_run_loggersexample already pinned elsewhere in this suite (cumulative means[4.0, 4.5, 6.0], k=3 half-width matching the published-t-table value 6.5724), plusstays_below_threshold’s “stays below, not first drops below” semantics on a purpose-built series that dips to zero deviation early and then spikes — mutation-proven to fail if the check compared only each row’s own deviation instead of the running suffix maximum — the always-False-at-k=1 edge case, a zero-cumulative-mean division producingNaNrather thaninf/an error, the empty-input error, and that a single value needs noscipyimport at all (only k>=2 does).plot_replication_analysisandTrialLogger’s two new delegating methods (plot_replication_analysis,get_replication_precision) are covered against the same hand-computed figures, including the CI-band trace’s bounds (mutation-proven against a swapped upper/lower), thatci_level/deviation_threshold/what/matcheach genuinely reach the underlying calculation (mutation-proven),show_deviation=Falsedropping the second panel, the no-complete-pairs/fewer-than-two-replications/missing-scipyerror paths, and that the newmarker_size=/line_width=both reach the cumulative-mean and deviation traces (mutation-proven) while defaulting to the previous hard-coded values- An independent Python-QA-engineer review of the above found the test suite trustworthy overall (all hand-computed reference values independently re-verified against scipy directly) but flagged four gaps, all closed:
TrialLogger.get_replication_precision’s ownwhat=/match=passthrough was untested (only its siblingplot_replication_analysishadmatch=coverage),replication_precision’s ownci_levelhad no direct test (only indirect coverage through the plot/TrialLoggerlayers),get_replication_precisionsucceeding at a single replication (unlikeplot_replication_analysis, it has non>=2guard) was unpinned, and the CI-band trace’sxarray was untested alongside itsybounds — each closed with a mutation-proven test entity_metric_by_arrivalis covered againstunequal_run_loggersfor basic shape/values, a dedicated fixture provingarrival_timetracksarrival_eventrather thanevent_durations’s ownfirst_time(full array, not spot values),rework_loop_loggerproving the arrival lookup always uses the entity’s earliest occurrence regardless ofmatch(mutation-proven against both amatch="last"scenario and a merge-key-includes-occurrencemutation), a missing-arrival-event-for-one-entity case givingNaNrather than a dropped row, botharrival_event-coincides-with-first_event/second_eventcases, and a raw frame with no run column proving theNA-to-NAmerge join actually works through the real code path.plot_metric_vs_arrival_timeandTrialLogger’s two new delegating methods are covered against the same hand-computed figures, includingcolour_by="run"as a full{trace name: values}mapping,rolling_window/rolling_timeexact arithmetic with both-edge shrinkage on a fixture designed so the two smoothing strategies give numerically distinct answers (not a coincidental match), thatwarm_upfilters byarrival_timerather thanfirst_time(mutation-proven, using a fixture where the two orderings disagree) and is applied before smoothing rather than after (mutation-proven, using a fixture where leaked pre-warm-up data would visibly shift the result), the mutually-exclusive/non-positiverolling_window/rolling_timevalidation errors, and thatmarker_size=/line_width=reach the scatter/trend-line traces (mutation-proven) while defaulting toplot_replication_analysis’s equivalent valuesVidigiStore/VidigiPriorityStore’s newlogger=auto-logging is covered end to end for both classes: exact start/end event pairs (entity_id, resource_id, default and label-derived event names) for both the immediate-availability and deferred/queued grant paths; that a queued request’s start time is the actual grant time rather than the request time (mutation-proven — reverting the deferred-callback hook to a synchronous log call at request time makes this fail, by logging the waiter’s start at the wrong time); an exception raised mid-resource-use still logs exactly one end event; a cancelled (reneged) request never logs a phantom start; the manualget_direct()/put()pattern andVidigiPriorityStore.return_item()called directly; an item lackingid_attributedegrading toresource_id=Noneinstead of raisingAttributeError;label=addingunique_resource_id; passing nologgerat all being a true no-op; a logger configured withentity_idomitted skipping the log and warning exactly once per store, not once per call; per-callstart_event/end_event/eventoverrides;pathway/**extra_fieldspassthrough; and that a no-arg.populate()top-up call leaves the pool’s label (and therefore its default event names) unchanged. In the same style astest_against_core_simpy.py’s cross-model equivalence checks, a further pair of tests runs one non-trivial multi-entity, queueing, priority-ordered scenario twice - once with hand-writtenEventLogger.log_resource_use_start/log_resource_use_endcalls, once withlogger=/entity_id=auto-logging and no manual calls at all - and asserts the resulting logs are identical, for both the context-manager andget_direct()/put()patterns; mutation-proven by temporarily droppingunique_resource_idfrom the auto-logged path and confirming the comparison failsVidigiStore/VidigiPriorityStore’s new return guard (rejecting a SimPy event orNonepassed toput()/return_item()) is covered for both classes: everysimpy.Eventsubclass a model realistically yields — bareEvent,Timeout,Condition,AllOf,Process, and an unfulfilledget_direct()request (withnot pending.triggeredasserted so the case matches the test name) — plusNone, all mutation-proven against weakening the guard to aNone-only check. The guard is proven to fire before any side effect: a rejected call leavesitemsand both queues byte-for-byte unchanged and, on alogger=store, writes zero log rows — each mutation-proven against moving the guard below the auto-log call or below the raw put. Valid returns are confirmed unaffected: a realVidigiResourceround-trips throughput()/return_item()and lands back in the pool, therequest()context manager still returns cleanly through its un-guarded__exit__, alogger=store still auto-logs a genuine release, generic non-resource contents (a string, an int) are still accepted — pinning the “no type constraint” decision — and thecancel_getdocstring’s advice is pinned end to end (returning the get event raises, returning its.valuesucceeds)plot_entity_timeline’s newreturn_fig=is covered as a mutation-proven pair: the default (False) callsfig.show()and returnsNone,return_fig=Truereturns thego.Figurewithout callingfig.show()at all - each proven to fail if the branch were inverted - plus a value check that the returned figure genuinely carries the requested entity’s own events- The new “
request()context manager exited withoutyield req” guard is covered for both store types: that the misuse warns (mutation-proven — the warning collapses to silent if the detection is narrowed to only the already-granted case), that the buggy entity ends up with noresource_userow and the unit is not leaked (both mutation-proven against skipping the cleanup), that correctas req: yield requsage stays silent and logs exactly one start/end, that an exception raised beforeyield reqdoes not add a spurious warning, and — pinned explicitly — the one case the guard cannot catch (a lone entity with a free unit whose timeout outlasts the grant). Separately,generate_animation_dfgains a value-level test that one resource unit released and re-acquired by three successive entities places every holder at the same slot (full{entity: (id, x, y)}mapping, not sampled), with the downstream “depart logged before resource_use hides the treatment snapshots” symptom pinned as current behaviour - The new
count/num_resources/n_waitingproperties get their own file, run against both store classes:countasserted as the whole per-interval sequence through a multi-entity queueing run (mutation-proven against reporting available units instead of in-use, and against a+1/-1fudge), held constant acrossVidigiPriorityStore‘s direct holder-to-waiter handoff (checked at the grant instant via a callback), unmoved bycancel_getreneging andfilter_fnrequests, accumulating over top-uppopulate()calls, and — mutation-proven — raisingRuntimeErrorrather than returning a negative when the pool was filled bypopulate_store()or hand.put().n_waitingis pinned as a whole sequence too. A pair of tests in thetest_against_core_simpy.pystyle runs one deterministic contention schedule againstsimpy.Resource/simpy.PriorityResourceand assertscountmatches tick for tick..capacityis pinned to the pool size (and tonum_resources, an explicitcapacity=, andfloat("inf")for a bare store), and the new over-capacityValueError— fromput(),return_item()and a context-manager double-return, but not while an exception propagates — is mutation-proven, alongsidestrict_capacity=Falseand top-uppopulate()still working.populate_store()’s newDeprecationWarningis pinned as firing even whenlabelis given, unlike the store classes’ narrower missing-labelwarning - The new synchronised-trace helpers gain their own test file:
add_subplot_panelswiring the private_grid_refso arow=2trace resolves,add_synchronised_traceappending to every frame with the exact trace map asserted and the ragged-countValueErrorraised before the figure is touched, the redraw auto-detect on/off/override for bar and secondary-axis traces (mutation-proven), and — the regression the olderexample_13method exhibits — that the existing stage-label and resource-icon traces are byte-identical after the call and never fall into a frame’s trace map (mutation-proven against the naivelist(range(len))mapping).add_synchronised_trace_from_dataframe’saccumulatesnapshot-vs-cumulative row slices andmatch="index"count-mismatch error (the silent failure behindexample_15) are asserted as full per-frame sequences vidigi.ciwgains its first dedicated test file: that the generator refactor leftevent_log_from_ciw_recs’s DataFrame byte-identical (mutation-proven against a droppeddepartrow and a wrong time field), thatevent_logger_from_ciw_recsreproduces the same rows in anEventLoggerwith no validation warnings, thatrun_numberis absent by default and stamped on every event when given, a whole-sequence assertion of one entity’s ordered events across both outputs, and thattrial_logger_from_ciw_recsnumbers runs1..N(or perrun_numbers=), sums to the right row count, and raises for an empty run list, a length mismatch, or a run that recorded nothingresource_icon_fontis covered for the resolved family and weight landing on the resource glyph trace (both fromcustom_resource_iconand from a per-eventresource_iconcolumn, the latter with the fulltextlist asserted), the weight override, the default leaving the trace’s font untouched, and the digit-in-nameValueErrorsurfacing through the new argument. Its independence fromentity_icon_fontis pinned in both directions - each font reaches only its own trace, and the two can be set to different fonts at once - each mutation-proven against applying the entity family to the resource trace and against writing to the wrong trace index; theentity_icon_font-does-not-reach-resource-glyphs test carries a comment marking it as the boundary the two arguments deliberately drawentity_resource_offset_yis covered on all three resource-icon code paths (glyph trace, plain dot trace, imagelayout.imagesentry) as the full list of y positions against the event’s own anchor, with the historic-10default pinned by its own test and a shifted value on each path mutation-proven against a flipped signhidden_run_beforeandstep_snapshot_reveal_pop_ingain their own test file: the full per-snapshothidden_run_beforeseries (not sampled entries) for a genuine arrival, an entity capped out and later revealed, and an entity that plays the overflow-row role before becoming individually visible - the last of these caught a real gap during development, where an entity’s own id “surviving” every snapshot under the overflow-row role was wrongly read as continuous presence, since that row is relabelled to a synthetic id before drawing and the entity’s own icon was never actually rendered; fixed and mutation-proven (reverting the fix leaves two dedicated tests failing). Also covered: the default’s byte-identical no-op, exactly one phantom row per reveal at the correct snapshot/position/icon (mutation-proven against an off-by-one lead and against an empty-string icon), that a genuine arrival and the overflow row itself never get a phantom, and that an entity landing squarely on the overflow row after being hidden - simultaneously satisfying and testing both exclusions on the same row - still gets nonespawn_in_from_arrivalgets its own test file: the default-on behaviour adding exactly one visible spawn row and one phantom at the arrival anchor for an eligible arrival (whole per-entity row set, not sampled),spawn_in_from_arrival=Falserestoring the pre-feature output, the no-"arrival"-anchor no-op, that entities present at (or one snapshot into) the window keep the fly-in, that no frames are added and no entity is ever in two places at once, and that the phantom mask still excludes reveals and overflow rows whenstep_snapshot_reveal_pop_inis on at the same time. The two-earlier-slots guard and the reveal-vs-arrival split are each mutation-proven. A latent collision the default flip surfaced - phantom rows carried the same zero-width character asICON_FLIP_MARKER, so the icon-flip CSS selector matched them - is now covered by the existing flip-marker contract tests- The attached
scenario/labelonEventLogger/TrialLoggeris covered for storage andsummary()surfacing, inheritance from constituentEventLoggers (and explicit-argument override), the between-run disagreement warning, and thatadd_logwarns but does not mutate the trial’sscenarioon a conflict. TheTrialLoggerresource-utilisationscenario=fallback is mutation-proven — reverting the fallback line makes aresource_map-only call raise instead of resolving. Pickle round-trips (path and buffer) for both classes are asserted to preserve the log,summary()and the attached objects, with the wrong-typeread_pickleand unpicklable-scenarioerror paths covered activity_occupancy_statsand the process-map node occupancy annotation are covered against the hand-computedemptying_queue_loggers/resource_use_loggersfixtures: the full step→(mean, min, max, median) mapping for bothacross_runsmodes on a two-run fixture where the two modes genuinely disagree (mutation-proven against swapping them), a combined queue+resource log, the queue-and-resource name collision warning, and the empty-log andinclude_queues=Falsepaths.discover_dfg(occupancy_stats=...)is checked to merge the columns onto the right nodes witharrival/departleftNaN(full mapping, mutation-proven against a wrong merge key),dfg_to_graphviz/process_nodes_and_edges_for_cytoscaperender the numbers only whenshow_occupancyand the columns are present (and neverKeyErroron a plain node table), andEventLogger.generate_dfg(occupancy_metrics=True)threadswarm_upand the raw (unfiltered) log through end to endcompare_replication_valuesis covered for disjoint samples (no CI overlap, low Welch’s-t p-value), identical samples (overlap, p=1.0), a zero-mean side (NaNdelta_pct, not a crash), and the fewer-than-2-replications-on-one-side case (ci_overlap=None); the overlap boolean is mutation-proven (swapping itsorforandis caught by the disjoint-samples test).TrialLogger.compare_event_duration_stat/.compare_resource_utilisationare checked against the analysis function’s own output directly, including label defaulting from each trial’s.labeland theTypeErrorguard on a non-TrialLoggerother;plot_scenario_comparison/plot_resource_utilisation_comparisonand theirTrialLoggerdelegators are checked for bar heights and error bars against the same reference rather than re-derived expectations, includingscenario_a/scenario_b(and eachTrialLogger’s own attached.scenario) resolving capacity independently per sideflag_outlier_runs/TrialLogger.get_outlier_runsare covered against hand-computed IQR fences (verified against the quartiles before being encoded as assertions), the full run→is_outliermapping rather than a spot check, a wider multiplier flagging fewer outliers, the negative-multiplier error, and the below-4-replications warning; the fence direction is mutation-proven (an inverted fence fails both the mapping and the wider-multiplier test)._beeswarm_offsets/plot_outlier_runsare covered by a “no two values withinspacingshare a row” invariant checked against a widely-spaced case, a dense cluster forced across several rows, and 50 random points (mutation-proven: loosening the spacing check to a quarter of its threshold fails both the cluster and random-data checks), plus the plotted points partitioned by outlier flag matchingflag_outlier_runs’s own output directly, the fence line/shape positions, and the no-outliers case omitting that trace entirelyevent_occurrence_rate/TrialLogger.get_event_occurrence_rateare covered against a Wilson-interval worked example hand-computed independently in plain Python (the published standard-normal 97.5th percentile, not scipy checking itself), the zero- and all-occurrences boundary cases (finite, notNaN, unlikemean_confidence_interval’s low-ncase), and then_runsdenominator: an explicitn_runsoverride is mutation-proven against a log-derived fallback that would undercount a run logging nothing at all, andTrialLogger.get_event_occurrence_rateis checked to always pass the trial’s true run count through_add_highlight_bandsgets its own test file, exercised directly rather than only through the plotting functions that call it: both bounds given draws the exact band; an open bound extends to the margin-padded data edge with no boundary line drawn there (mutation-proven - swapping which side gets the margin, or clamping the open end to the bare data value instead of padding it, both fail this), an explicit bound further out than the data widens the shared margin computation itself, multiple bands pin one combined axis range, a labelled band’s legend-proxy trace carries the band’s own colour and is absent when unlabelled, bothorientation="x"/"y", bothValueErrorpaths (lower >= upper; neither bound set), and a zero-range dataset still produces a non-zero margin rather than collapsing the band to a sliver.plot_metric’skind="bar"output is checked directly againstplot_metric_bar’s own output for the same inputs (bar heights, CI error bars, show_runs points) rather than re-derived independently, so the deprecation message’s “drop-in replacement” claim is actually true;kind="box"/"violin"are covered for the per-run values carried through,across="entities"anderror_barsboth correctly rejected, andshow_runstogglingboxpoints/points(mutation-proven against the guard being dropped).plot_metric_barand itsTrialLoggerdelegator are pinned to emit exactly oneDeprecationWarningnaming the replacement.plot_resource_utilisation’s newkind=is checked against the fixture’s own hand-computed per-run utilisation values, andhighlight_bandsend-to-end through every one ofplot_duration_distribution,plot_metricandplot_resource_utilisation, plus eachTrialLoggerdelegator, proving the new parameter actually reaches the underlyingvidigi.plotscall rather than being silently dropped in the delegationhighlight_bandsis extended to five more plotting functions and theirTrialLoggerdelegators -plot_metric_vs_arrival_time,plot_scenario_comparison/plot_event_duration_comparison,plot_resource_utilisation_comparison,plot_resource_utilisation_over_timeandplot_queue_size- each covered for a labelled band adding both the shaded shapes and its legend proxy trace, and the no-bands case adding no shapes at all.plot_resource_utilisation_over_time/plot_queue_size(with bothbackend="express"and"go") additionally get a two-facet fixture proving a band spans every panel (one rect per y-axis,{"y", "y2"}), not just the first - the specific case_add_highlight_bands’s newrow="all", col="all"argument exists to fix, verified empirically beforehand (a plainadd_hrect/add_hlinecall with norow=/col=only ever draws on a faceted figure’s first subplot)
1.3.1
- Add support for pandas 3.13 and 3.14
- Handle pandas FutureWarning that was outputting multiple warnings for Pandas 2.2.0 and above relating to handling of grouping columns in apply
- Subsequently replaced this entire block with a different, more performant approach that is agnostic to pandas version
- Handle pandas FutureWarning around TimeDeltaIndex unit keyword deprecation for Pandas 2.2.0 and above
1.3.0
Enhancements
Fixes
- Changes to generate_animation_df to fix bug where entities would sometimes seem to reappear from the top left for their final exit step
- Change default for step_snapshot_max to 60 (from 50) so that if you use the default for this and wrap_queues_at (20), you won’t end up triggering a warning (thanks Amy!)
- Add a better default for the set_limit_duration parameter. This now defaults to the maximum time seen in the simulation rather than 1440, which was equivalent to 1 day in minutes (thanks Amy!)
New examples
- Example added of using vidigi with Salabim (thanks Amy!)
1.2.2
Examples
- New example available of how to use vidigi to visualise an agent based simulation
Fixes
- Fixes to custom hover data assignment so that custom hover fields are now available
- Fixes to custom hover data assignment in case of no scenario being specified
- Fixes to docstring around custom hover text definition (incorrectly said to use column names rather than customdata[0] notation)
1.2.1
- Major redesign of documentation
- Add
EventLogger.generate_dfg()method that wraps the various dfg functions for convenient access. - Add extra debug print statement to show start time of first animation transformation step.
1.2.0
- [EXPERIMENTAL] Add support for creating directly-follows-graphs (process maps) from vidigi event logs
- in Graphviz.
- in jupyter notebooks using ipycytoscape.
- Streamlit using streamlit-cytoscape.
e.g. 
1.1.1
- Fix minor bug with default hover text where incorrect/confusing time unit could display next to snapshot time in hover
1.1.0
- Add TrialLogger class, allowing storage of multiple EventLogger objects and access to new helper functions for trial summarisation and plotting
- Fix plot_entity_timeline() method in EventLogger
- Add from_csv() option for generating EventLogger object from existing dataframe, allowing access to EventLogger’s helper functions even when you have not used it for the initial logging
- Improve default hover tooltip for entities
- Add support for custom hover tooltips for entities
- Add helper function for including ASCII gauges to better visualise large queues
- Added option to swap out ‘+ x more’ text for an ASCII gauge in generate_animation and animate_activity_log
- Improved how ‘+ x more’ and ASCII gauges are handled as entities, preventing them from flying in/out on most frame updates
- Various improvements to type hinting and documentation of functions
- Added various warnings for incorrect types in core functions
- Added warning when step_snapshot_limit is not a multiple of wrap_queues_at (which can cause odd behaviour for placement of the ‘+ x more’ text or ASCII excess queue gauge)
- Added and refined several documentation pages
- Minor refactoring and efficiencies to reshape_for_animations function
- Added some very basic tests for reshape_for_animations function
1.0.2
- Bump minimum Python version to 3.10 to simplify support for install across both conda-forge and python. 3.9 is no longer going to be supported after October 2025.
1.0.1
- Added ‘background_image_opacity’ argument to generate_animation and animate_activity_log. Default opacity is 0.5, which matches the previous hardcoded value.
- Added ‘overflow_text_color’ argument to generate_animation and animate_activity_log. Default is ‘black’. Overflow text refers to the ‘+ x more’ text that appears when queue lengths exceed the snapshot size.
- Added ‘stage_label_text_colour’ argument to generate_animation and animate_activity_log. Default is ‘black’. These are the optional labels showing the stages as defined in the event position dataframe, which you may be using instead of passing in a custom background with stage labels.
- Add ability to log custom events with non-standard event_type using the .log_custom_event() method of the EventLogger class.
- Fully empty columns are now automatically removed when
- exporting event log as df or csv
- using prep.reshape_for_animation
- using prep.generate_animation_df
- Experimental: Added a graph objects backend (alternative to plotly express). This is not recommended for active use - it primarily exists to help explore ways in which further customisations could be applied to the plot.
1.0.0
Migration guide below!
Changelog
BREAKING CHANGES
- significant changes to
VidigiPriorityStore- BREAKING: the original implementation of
VidigiPriorityStorehas been renamed toVidigiPriorityStoreLegacy
- BREAKING: the original implementation of
- default entity column name for all prep and animation functions is now ‘entity_id’ rather than ‘patient’. This can be managed by passing in the argument
entity_col_name="patient"to each of these functions. - various classes and functions have been moved into more appropriate files, rather than all existing in
Utils.- VidigiStore, VidigiPriorityStore, VidigiPriorityStoreLegacy and other resources are now in
vidigi.resources - EventLogger is now in
vidigi.logging
- VidigiStore, VidigiPriorityStore, VidigiPriorityStoreLegacy and other resources are now in
- parameter
icon_and_text_sizehas been removed and replaced with separate parametersresource_icon_sizeentity_icon_sizetext_size
- parameter
gap_between_rowshas been removed and replaced with separate parameters for queues and resourcesgap_between_queue_rowsgap_between_resource_rows
- CustomResource is now called VidigiResource. This generally should not cause problems as you are likely to only be accessing it indirectly through use of VidigiStore or VidigiPriorityStore.
init_itemsargument for VidigiStore and VidigiPriorityStore has been replaced withnum_resources. Defaulting to none, this functions identically to thepopulate_storesfunction, but instead allows you to initialise the resource on start.- the dataframe expected by
generate_animation_dfis nowfull_entity_df, notfull_patient_df. Only the parameter name needs updating. - the dataframe expected by
generate_animationis nowfull_entity_df_plus_pos, notfull_patient_df_plus_pos. Only the parameter name needs updating.
NEW FEATURES:
Adds
- an additional
VidigiStoreclass to replace use of standard store - tests to ensure identical functioning of VidigiStore, VidigiPriorityStore and VidigiPriorityStoreLegacy to their core simpy counterparts
The benefit of these new classes is that they allow the common resource requesting patterns to be used
So
with self.nurse.request() as req:
# Freeze the function until the request for a nurse can be met.
# The patient is currently queuing.
yield reqwill work when using a VidigiStore or VidigiPriorityStore - mimicking the syntax of making a request from resources - while supporting the inclusion of a resource ID attribute (not possible with traditional simpy resources) that is necessary to grab for simpy.
To access the attribute, it does necessitate some small change -
with self.nurse.request() as req:
# Freeze the function until the request for a nurse can be met.
# The patient is currently queuing.
nurse_resource = yield req ## NEED TO ASSIGN HERESo req.id_attribute would not work
but
nurse_resource.id_attribute would
This is hopefully still a far less substantial change than was required previously, where models using resources had to switch to using .get() and .put().
Further testing still required for more complex request logic that incorporates aspects like reneging.
Additional new features:
- allow flexible naming of all key input columns - so you’re no longer limited to ‘patient’, ‘event’, ‘event_type’, ‘resource_id’, ‘time’, ‘pathway’.
- these are now controlled with the parameters
entity_col_name,event_col_name,event_type_col_name,resource_col_name", "time_col_name", "pathway_col_name
- these are now controlled with the parameters
- add helper class for event logging (
from vidigi.logging import EventLogger) - add helper class and function for generating an event positioning dataframe (
from vidigi.utils import EventPosition, create_event_position_df) - add helper function for generating a repeating overlay to the final animation, e.g. to make it clear when something like night or a clinic closure is occurring (
from vidigi.animation import add_repeating_overlay - add in a wide range of additional ways that the simulation time can be displayed (e.g. ‘Simulation Day 1’, am/pm rather than 24 hour, or even custom strftime string)
BUGFIXES
- fix bugs preventing the generation of ‘resourceless’ animations
- fix bugs relating to resource wrapping with multiple pools
- prevent shifting of entities to the exit position on the final frame
- fix bug leading to skipped frames when no entities present
- fix bugs with ordering of ciw logs
- fix bug with incorrect end type for resource use in ciw logs
- ensure sim start and end time are respected in different situations
- ensure sensible behaviour when start_time parameter is provided but start_date is not
- ensure exit step always shown
OTHER
- bump ciw example from 2.x to 3.x
- add more complex ciw example
- add resourceless queue examples
- add multiple concurrent trace example
🚀 Migration Guide: vidigi 0.0.4 → 1.0.0
This guide will help you update your code and workflows to work with vidigi version 1.0.0, which includes breaking changes, new features, and important bug fixes.
⚠️ Breaking Changes
1. Default Entity Column Name
Was: 'patient' Now: 'entity_id'
Update your function calls OR change your entity ID column name to entity_id:
# Before
animate_activity_log(event_log, event_position_df)
# After
animate_activity_log(event_log, event_position_df, entity_col_name="patient")
2. Module Reorganization
Some classes and functions have moved:
| Old Location | New Location |
|---|---|
vidigi.utils.VidigiPriorityStore |
vidigi.resources.VidigiPriorityStoreLegacy |
Update your import statements accordingly.
3. Visual Parameter Changes
icon_and_text_size→ replaced with:resource_icon_sizeentity_icon_sizetext_size
gap_between_rows→ replaced with:gap_between_queue_rowsgap_between_resource_rows
4. Parameter names for main dataframes in step-by-step functions
- the dataframe expected by
generate_animation_dfis nowfull_entity_df, notfull_patient_df. Only the parameter name needs updating. - the dataframe expected by
generate_animationis nowfull_entity_df_plus_pos, notfull_patient_df_plus_pos. Only the parameter name needs updating.
5. CustomResource Renamed
CustomResource is now VidigiResource. This is typically used indirectly through VidigiStore or VidigiPriorityStore, so minimal changes may be needed unless you were using it directly.
6. Resource Initialization Parameter
init_items has been replaced with num_resources in VidigiStore and VidigiPriorityStore.
Example:
Before
resource_store = VidigiStore(simulation_env, init_items=[...])
OR
resource_store = simpy.Store(simulation_env)
populate_store(5, resource_store, simulation_env)
After
resource_store = VidigiStore(simulation_env, num_resources=3)
✨ New Features
✅ Flexible Column Names
You can now customize column names in the animation and animation prep functions, meaning you are no longer tied to using ‘patient’ for your entity IDs!
entity_col_nameevent_col_nameevent_type_col_nameresource_col_nametime_col_namepathway_col_name
Defaults are
- entity_id
- event
- event_type
- resource_id
- time
- pathway
(note ‘pathway’ is an optional column you may choose not to populate)
✅ What You Should Do
If you run into issues or have questions, check out the documentation or open an issue on the repo. Thanks for upgrading!