66"""
77
88import logging
9+ import math
910import random
1011from datetime import datetime , timedelta
1112from pathlib import Path
@@ -210,6 +211,20 @@ def _initialize(self) -> None:
210211 # Phase 5.1: Generate domain SID and per-user SID registry
211212 sid_registry = self ._build_sid_registry ()
212213
214+ # Phase 5.5: Generate per-user timing and behavioral offsets
215+ rng = random .Random (hash (self .scenario .name + "_offsets" ))
216+ self ._user_time_offsets : dict [str , dict [str , float ]] = {}
217+ for user in self .scenario .environment .users :
218+ self ._user_time_offsets [user .username ] = {
219+ 'start_offset' : rng .gauss (0 , 0.25 ), # ~±15min work start
220+ 'end_offset' : rng .gauss (0 , 0.25 ), # ~±15min work end
221+ 'lunch_start_offset' : rng .gauss (0 , 0.17 ), # ~±10min lunch start
222+ 'lunch_duration_offset' : rng .gauss (0 , 0.12 ), # ~±7min lunch length
223+ 'intensity_bias' : rng .uniform (0.8 , 1.2 ), # ±20% event intensity
224+ 'cluster_size_bias' : rng .gauss (0 , 0.2 ), # ±20% cluster size
225+ 'inter_gap_bias' : rng .gauss (0 , 0.15 ), # ±15% gap timing
226+ }
227+
213228 # Initialize activity generator
214229 self .activity_generator = ActivityGenerator (
215230 state_manager = self .state_manager ,
@@ -264,15 +279,22 @@ def _generate_baseline(self) -> None:
264279 for user in enabled_users :
265280 # Resolve persona for work hours and risk modulation
266281 persona = self ._get_user_persona (user )
282+ user_offsets = self ._user_time_offsets .get (user .username )
267283
268284 # Calculate events for this user this hour
269285 num_events = self ._calculate_events_for_hour (
270- user , current_hour = current_hour .hour , persona = persona
286+ user , current_hour = current_hour .hour , persona = persona ,
287+ user_offsets = user_offsets ,
271288 )
272289
273290 if num_events > 0 :
274- # Distribute events across the hour
275- event_times = self ._distribute_events_in_hour (current_hour , num_events )
291+ # Distribute events across the hour (clustered)
292+ persona_name = user .persona if user .persona else None
293+ event_times = self ._distribute_events_in_hour (
294+ current_hour , num_events ,
295+ persona_name = persona_name ,
296+ username = user .username ,
297+ )
276298
277299 # Generate user activity at each time
278300 for event_time in event_times :
@@ -434,23 +456,100 @@ def _get_user_persona(self, user: User) -> Optional[Persona]:
434456 return persona
435457 return None
436458
459+ @staticmethod
460+ def _sigmoid (x : float ) -> float :
461+ """Sigmoid function for smooth temporal transitions."""
462+ return 1.0 / (1.0 + math .exp (- 6.0 * x ))
463+
464+ def _work_hour_multiplier (
465+ self ,
466+ hour : int ,
467+ whp : dict ,
468+ user_offsets : Optional [dict ] = None ,
469+ ) -> float :
470+ """Calculate activity multiplier based on work hours with smooth transitions.
471+
472+ Returns 0.0–1.5 multiplier. Uses sigmoid ramps for gradual transitions
473+ at work start/end and lunch, instead of binary on/off.
474+
475+ Args:
476+ hour: Integer hour of day (0-23)
477+ whp: work_hours_parsed dict with start, end, lunch, peak_hours
478+ user_offsets: Optional per-user timing offsets
479+
480+ Returns:
481+ Activity multiplier (0.02–1.5)
482+ """
483+ start = whp ['start' ]
484+ end = whp ['end' ]
485+ lunch = whp .get ('lunch' ) # (start_hour, end_hour) or None
486+ peak_hours = whp .get ('peak_hours' ) or []
487+
488+ # Apply per-user offsets if provided
489+ if user_offsets :
490+ start += user_offsets .get ('start_offset' , 0 )
491+ end += user_offsets .get ('end_offset' , 0 )
492+ if lunch :
493+ lunch_start = lunch [0 ] + user_offsets .get ('lunch_start_offset' , 0 )
494+ lunch_dur_offset = user_offsets .get ('lunch_duration_offset' , 0 )
495+ lunch_end = lunch [1 ] + user_offsets .get ('lunch_start_offset' , 0 ) + lunch_dur_offset
496+ lunch = (lunch_start , lunch_end )
497+
498+ h = float (hour ) + 0.5 # Use mid-hour for smoother curve
499+
500+ # Morning ramp-up: sigmoid from start-1.5 to start
501+ if h < start - 1.5 :
502+ return 0.02 # Near-zero early morning
503+ if h < start + 0.5 :
504+ t = (h - (start - 1.0 )) / 1.5 # 0 to 1 over transition
505+ return 0.02 + 0.98 * self ._sigmoid (t * 2 - 1 )
506+
507+ # Evening ramp-down: sigmoid from end to end+1.5
508+ if h > end + 1.5 :
509+ return 0.02 # Near-zero late evening
510+ if h > end - 0.5 :
511+ t = (h - (end - 0.5 )) / 1.5 # 0 to 1 over transition
512+ return 0.02 + 0.98 * (1.0 - self ._sigmoid (t * 2 - 1 ))
513+
514+ # Lunch dip (soft, 50% not 0%)
515+ if lunch :
516+ lunch_start , lunch_end = lunch
517+ lunch_mid = (lunch_start + lunch_end ) / 2.0
518+ lunch_half = (lunch_end - lunch_start ) / 2.0
519+ if lunch_start - 0.5 < h < lunch_end + 0.5 :
520+ # Smooth dip centered on lunch mid-point
521+ dist_from_mid = abs (h - lunch_mid )
522+ if dist_from_mid < lunch_half :
523+ return 0.5 # Core lunch: 50%
524+ else :
525+ # Transition zone (0.5h on each side)
526+ t = (dist_from_mid - lunch_half ) / 0.5
527+ return 0.5 + 0.5 * min (1.0 , t ) # Ramp 0.5 → 1.0
528+
529+ # Peak hours: 1.5x
530+ if hour in peak_hours :
531+ return 1.5
532+
533+ # Normal work hours
534+ return 1.0
535+
437536 def _calculate_events_for_hour (
438537 self ,
439538 user : User ,
440539 current_hour : Optional [int ] = None ,
441540 persona : Optional [Persona ] = None ,
541+ user_offsets : Optional [dict ] = None ,
442542 ) -> int :
443543 """Calculate number of events for user this hour.
444544
445- Applies intensity + variation + persona risk profile + work hours
545+ Applies intensity + variation + persona risk profile + sigmoid work hours
446546 to determine how many events to generate for this user during this hour.
447547
448- Phase 2.6: Uses persona data for time-of-day modulation and risk scaling.
449-
450548 Args:
451549 user: User to calculate events for
452550 current_hour: Hour of day (0-23) for work hours modulation
453551 persona: Resolved Persona object for risk/work-hours modulation
552+ user_offsets: Optional per-user timing offsets
454553
455554 Returns:
456555 Number of events to generate (>= 0)
@@ -459,18 +558,21 @@ def _calculate_events_for_hour(
459558 intensity_map = {'low' : 5 , 'medium' : 15 , 'high' : 40 }
460559 base_events = intensity_map [self .scenario .baseline_activity .intensity ]
461560
462- # Phase 2.6: Risk profile multiplier
561+ # Risk profile multiplier
463562 if persona and persona .risk_profile :
464563 risk_mult = {'low' : 0.7 , 'medium' : 1.0 , 'high' : 1.3 }
465564 base_events = int (base_events * risk_mult .get (persona .risk_profile , 1.0 ))
466565
467- # Phase 2.6: Work hours modulation
566+ # Phase 5.5: Sigmoid work hours modulation (replaces binary on/off)
468567 if persona and persona .work_hours_parsed and current_hour is not None :
469- whp = persona .work_hours_parsed
470- if current_hour not in whp ['hours' ]:
471- return 0 # Outside work hours — no activity
472- elif current_hour in (whp .get ('peak_hours' ) or []):
473- base_events = int (base_events * 1.5 ) # Peak hours: 150%
568+ multiplier = self ._work_hour_multiplier (
569+ current_hour , persona .work_hours_parsed , user_offsets
570+ )
571+ base_events = int (base_events * multiplier )
572+
573+ # Phase 5.5: Per-user intensity bias (so two same-persona users differ)
574+ if user_offsets and 'intensity_bias' in user_offsets :
575+ base_events = int (base_events * user_offsets ['intensity_bias' ])
474576
475577 # Apply variation (random jitter)
476578 variation_map = {'low' : 0.10 , 'medium' : 0.25 , 'high' : 0.50 }
@@ -479,8 +581,17 @@ def _calculate_events_for_hour(
479581
480582 return num_events
481583
482- def _distribute_events_in_hour (self , hour_start : datetime , num_events : int ) -> list [datetime ]:
483- """Distribute events across hour with uniform distribution + jitter.
584+ # Phase 5.5: Per-persona cluster configuration
585+ PERSONA_CLUSTER_CONFIG = {
586+ 'developer' : {'cluster_size' : (5 , 15 ), 'inter_gap_mean' : 600 },
587+ 'executive' : {'cluster_size' : (2 , 6 ), 'inter_gap_mean' : 300 },
588+ 'analyst' : {'cluster_size' : (4 , 10 ), 'inter_gap_mean' : 480 },
589+ 'sysadmin' : {'cluster_size' : (3 , 8 ), 'inter_gap_mean' : 360 },
590+ 'default' : {'cluster_size' : (3 , 10 ), 'inter_gap_mean' : 420 },
591+ }
592+
593+ def _distribute_events_in_hour_uniform (self , hour_start : datetime , num_events : int ) -> list [datetime ]:
594+ """Distribute events uniformly (legacy fallback).
484595
485596 Args:
486597 hour_start: Start of the hour
@@ -492,15 +603,68 @@ def _distribute_events_in_hour(self, hour_start: datetime, num_events: int) -> l
492603 if num_events == 0 :
493604 return []
494605
495- # Uniform spacing with jitter (±25% of interval)
496- interval = 3600 / num_events # seconds per event
606+ interval = 3600 / num_events
497607 times = []
498-
499608 for i in range (num_events ):
500- # Base time with jitter
501609 offset = interval * i + random .uniform (- interval * 0.25 , interval * 0.25 )
502- offset = max (0 , min (3600 , offset )) # Clamp to hour [0, 3600]
610+ offset = max (0 , min (3599 , offset ))
503611 times .append (hour_start + timedelta (seconds = offset ))
612+ return sorted (times )
613+
614+ def _distribute_events_in_hour (
615+ self ,
616+ hour_start : datetime ,
617+ num_events : int ,
618+ persona_name : Optional [str ] = None ,
619+ username : Optional [str ] = None ,
620+ ) -> list [datetime ]:
621+ """Distribute events in activity clusters within an hour.
622+
623+ Phase 5.5: Replaces uniform spacing with realistic bursty clusters.
624+ Events within a cluster are spaced 0.5-3 seconds apart.
625+ Inter-cluster gaps follow exponential distribution.
626+
627+ Args:
628+ hour_start: Start of the hour
629+ num_events: Number of events to distribute
630+ persona_name: Optional persona for cluster config
631+ username: Optional username for per-user variation
632+
633+ Returns:
634+ List of event times sorted chronologically
635+ """
636+ if num_events == 0 :
637+ return []
638+
639+ # Get persona-specific cluster config
640+ config = self .PERSONA_CLUSTER_CONFIG .get (
641+ (persona_name or '' ).lower (),
642+ self .PERSONA_CLUSTER_CONFIG ['default' ]
643+ )
644+ cluster_min , cluster_max = config ['cluster_size' ]
645+ inter_gap_mean = config ['inter_gap_mean' ]
646+
647+ # Apply per-user variation
648+ if username and hasattr (self , '_user_time_offsets' ):
649+ offsets = self ._user_time_offsets .get (username , {})
650+ size_bias = 1.0 + offsets .get ('cluster_size_bias' , 0 )
651+ cluster_min = max (2 , int (cluster_min * size_bias ))
652+ cluster_max = max (cluster_min + 1 , int (cluster_max * size_bias ))
653+ gap_bias = 1.0 + offsets .get ('inter_gap_bias' , 0 )
654+ inter_gap_mean = max (60 , inter_gap_mean * gap_bias )
655+
656+ times = []
657+ remaining = num_events
658+ t = random .expovariate (1.0 / 60 ) # First cluster offset (mean ~1min)
659+
660+ while remaining > 0 :
661+ cluster_size = min (remaining , random .randint (cluster_min , cluster_max ))
662+ for i in range (cluster_size ):
663+ event_t = t + random .uniform (0.5 , 3.0 ) * i
664+ times .append (hour_start + timedelta (seconds = min (event_t , 3599 )))
665+ remaining -= cluster_size
666+ # Inter-cluster gap: exponential distribution
667+ t += cluster_size * 2.0 + random .expovariate (1.0 / inter_gap_mean )
504668
505669 return sorted (times )
506670
0 commit comments