sim-tools 1.0.4__tar.gz → 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sim-tools
3
- Version: 1.0.4
3
+ Version: 1.2.0
4
4
  Summary: Simulation Tools for Education and Practice
5
5
  Project-URL: Homepage, https://github.com/sim-tools/sim-tools
6
6
  Project-URL: Bug Tracker, https://github.com/sim-tools/sim-tools/issues
@@ -1,5 +1,5 @@
1
1
  """sim-tools"""
2
2
 
3
- __version__ = "1.0.4"
3
+ __version__ = "1.2.0"
4
4
 
5
5
  from . import datasets, distributions, time_dependent, ovs, output_analysis
@@ -100,6 +100,7 @@ from typing import (
100
100
  Dict,
101
101
  runtime_checkable,
102
102
  TypeVar,
103
+ Callable,
103
104
  )
104
105
 
105
106
  import numpy as np
@@ -257,7 +258,7 @@ class DistributionRegistry:
257
258
  >>> exp_dist = DistributionRegistry.create("Exponential", mean=2.0)
258
259
  >>> exp_dist.sample(5) # Generate 5 samples
259
260
 
260
- Create multiple distributions from configuration:
261
+ Create multiple distributions from a flat configuration:
261
262
 
262
263
  >>> config = {
263
264
  ... "arrivals": {
@@ -270,22 +271,53 @@ class DistributionRegistry:
270
271
  ... }
271
272
  ... }
272
273
  >>> distributions = DistributionRegistry.create_batch(config,
273
- main_seed=12345)
274
+ ... main_seed=12345)
274
275
  >>> arrivals = distributions["arrivals"]
275
276
  >>> service_times = distributions["service_times"]
276
277
 
278
+ Create distributions from a nested configuration:
279
+
280
+ >>> config = {
281
+ ... "call": {
282
+ ... "C1": {"class_name": "Exponential", "params": {"mean": 10.0}},
283
+ ... "C2": {"class_name": "Exponential", "params": {"mean": 8.0}},
284
+ ... },
285
+ ... "response_time": {
286
+ ... "C1": {
287
+ ... "class_name": "Lognormal",
288
+ ... "params": {"mean": 30.0, "stdev": 5.0},
289
+ ... },
290
+ ... },
291
+ ... }
292
+ >>> dists = DistributionRegistry.create_batch(
293
+ ... config, main_seed=42, preserve_structure=True
294
+ ... )
295
+ >>> dists["call"]["C1"].sample(5)
296
+ >>> dists["response_time"]["C1"].sample(5)
297
+
277
298
  Notes
278
299
  -----
279
300
  When creating distributions in batch with a main_seed, each distribution
280
301
  receives its own statistically independent seed derived from the main seed.
281
302
  This ensures proper statistical independence between random number streams
282
303
  while maintaining overall reproducibility through the main seed.
304
+
305
+ When `preserve_structure=True`, the output mirrors the shape of the
306
+ input configuration, so nested configs produce nested dicts of instances.
307
+ When `preserve_structure=False` (default), all leaf distributions are
308
+ flattened into a single dict whose keys are the path components joined by
309
+ underscores (e.g. `"call_C1"`).
310
+
311
+ With `sort=True` (default), keys at every nesting level are sorted
312
+ alphabetically before seeds are assigned. This ensures that seed
313
+ assignment is stable even if the insertion order of keys in the config
314
+ dict changes between runs.
283
315
  """
284
316
 
285
- _registry = {}
317
+ _registry: Dict[str, type] = {}
286
318
 
287
319
  @classmethod
288
- def register(cls, name: Optional[str] = None):
320
+ def register(cls, name: Optional[str] = None) -> Callable:
289
321
  """
290
322
  Decorator to register a distribution class in the registry.
291
323
 
@@ -310,7 +342,7 @@ class DistributionRegistry:
310
342
  return decorator
311
343
 
312
344
  @classmethod
313
- def get(cls, name: str):
345
+ def get(cls, name: str) -> type:
314
346
  """
315
347
  Get a distribution class by name.
316
348
 
@@ -353,43 +385,146 @@ class DistributionRegistry:
353
385
  distribution_class = cls.get(name)
354
386
  return distribution_class(**params)
355
387
 
388
+ @classmethod
389
+ def _is_dist_config(cls, obj) -> bool:
390
+ """
391
+ Return True if `obj` is a valid distribution config leaf.
392
+
393
+ A valid leaf is a dict with exactly the keys `'class_name'` and
394
+ `'params'` and no others.
395
+
396
+ Parameters
397
+ ----------
398
+ obj : object
399
+ The object to test.
400
+
401
+ Returns
402
+ -------
403
+ bool
404
+ """
405
+ return (
406
+ isinstance(obj, dict)
407
+ and set(obj.keys()) == {"class_name", "params"}
408
+ )
409
+
356
410
  @classmethod
357
411
  def create_batch(
358
412
  cls,
359
413
  config: Union[List[Dict], Dict[str, Dict]],
360
414
  main_seed: Optional[int] = None,
361
- sort: Optional[bool] = True
415
+ sort: Optional[bool] = True,
416
+ preserve_structure: bool = False,
362
417
  ) -> Union[List, Dict]:
363
418
  """
364
419
  Create multiple distributions from a configuration dictionary or list.
365
420
 
421
+ Accepts both flat and arbitrarily nested dict configurations. Every
422
+ leaf must be a dict with exactly the keys `'class_name'` and
423
+ `'params'`.
424
+
366
425
  Parameters
367
426
  ----------
368
427
  config : Union[List[Dict], Dict[str, Dict]]
369
- Either:
370
- - A list of distribution configs, each with 'class_name' and
371
- 'params'
372
- - A dictionary mapping names to distribution configs
428
+ One of:
429
+
430
+ - A **list** of distribution configs, each with `'class_name'`
431
+ and `'params'`. Returns a list of instances.
432
+ - A **flat dict** mapping names to distribution configs:
433
+
434
+ {
435
+ "arrivals": {
436
+ "class_name": "Exponential",
437
+ "params": {"mean": 5.0},
438
+ }
439
+ }
440
+
441
+ - A **nested dict** where intermediate keys group distributions
442
+ and leaves are distribution configs:
443
+
444
+ {
445
+ "call": {
446
+ "C1": {
447
+ "class_name": "Exponential",
448
+ "params": {"mean": 10.0},
449
+ }
450
+ }
451
+ }
452
+
373
453
  main_seed : Optional[int], default=None
374
454
  Master seed to generate individual seeds for each distribution.
375
455
  If None, random seeds will still be generated for independence.
376
456
  sort : Optional[bool], default=True
377
- If True and config is dict, sort configs before assigning seeds,
378
- ensuring deterministic results if the config key order changes. Not
379
- relevant for lists as they are unnamed.
457
+ If True, keys at every nesting level are sorted alphabetically
458
+ before seeds are assigned. This ensures deterministic seed
459
+ assignment even if config key insertion order changes between
460
+ runs. Not relevant for top-level lists.
461
+ preserve_structure : Optional[bool], default=False
462
+ Controls the shape of the returned dict when `config` is a dict.
463
+
464
+ - `False` (default): all distributions are returned in a single
465
+ flat dict whose keys are the path components of each leaf joined
466
+ by underscores, e.g. `"call_C1"`.
467
+ - `True`: the returned dict mirrors the nesting of the input
468
+ config, so `result["call"]["C1"]` gives the distribution
469
+ directly.
470
+
471
+ Has no effect when `config` is a list.
380
472
 
381
473
  Returns
382
474
  -------
383
475
  Union[List, Dict]
384
- Either:
385
- - A list of distribution instances (if config was a list)
386
- - A dictionary mapping names to distribution instances
476
+ - A list of distribution instances if `config` was a list.
477
+ - A flat dict of distribution instances if `config` was a dict
478
+ and `preserve_structure=False`.
479
+ - A nested dict of distribution instances if `config` was a dict
480
+ and `preserve_structure=True`.
387
481
 
388
482
  Raises
389
483
  ------
390
484
  TypeError
391
- If config is neither a list nor a dictionary
392
- """
485
+ If `config` is neither a list nor a dictionary.
486
+ ValueError
487
+ If any distribution config is malformed, if a required key is
488
+ missing or an unexpected key is present, or if the config
489
+ contains values that are neither dicts, lists, nor valid
490
+ distribution configs.
491
+ """
492
+ flat_items = []
493
+
494
+ def walk(node, path=()):
495
+ if cls._is_dist_config(node):
496
+ flat_items.append((path, node))
497
+ return
498
+
499
+ if isinstance(node, dict):
500
+ # Detect a malformed distribution config: has one of the
501
+ # expected keys but not the exact right set, which most
502
+ # likely means a typo or missing key in a leaf config.
503
+ if "class_name" in node or "params" in node:
504
+ expected = {"class_name", "params"}
505
+ raise ValueError(
506
+ f"Distribution config at path {path!r} must have "
507
+ f"ONLY the keys {expected}. "
508
+ f"Found keys: {set(node.keys())}"
509
+ )
510
+ items = node.items()
511
+ if sort:
512
+ items = sorted(items, key=lambda kv: kv[0])
513
+ for key, value in items:
514
+ walk(value, path + (key,))
515
+ return
516
+
517
+ if isinstance(node, list):
518
+ for i, value in enumerate(node):
519
+ walk(value, path + (i,))
520
+ return
521
+
522
+ raise ValueError(
523
+ f"Expected a distribution config dict, a nested grouping "
524
+ f"dict, or a list at path {path!r}. "
525
+ f"Got {type(node).__name__!r}."
526
+ )
527
+
393
528
  if isinstance(config, list):
394
529
  seeds = spawn_seeds(len(config), main_seed)
395
530
  return [
@@ -397,33 +532,48 @@ class DistributionRegistry:
397
532
  for i, dist_config in enumerate(config)
398
533
  ]
399
534
 
400
- if isinstance(config, dict):
401
- items = list(config.items())
402
- if sort:
403
- items = sorted(items, key=lambda kv: kv[0])
404
- seeds = spawn_seeds(len(items), main_seed)
405
- return {
406
- name: cls._validate_and_create(dist_config, seeds[i])
407
- for i, (name, dist_config) in enumerate(items)
408
- }
535
+ if not isinstance(config, dict):
536
+ raise TypeError(
537
+ "Configuration must be a list or dictionary, "
538
+ f"got {type(config).__name__!r}."
539
+ )
540
+
541
+ walk(config)
542
+ seeds = spawn_seeds(len(flat_items), main_seed)
543
+
544
+ created = [
545
+ (path, cls._validate_and_create(dist_config, seeds[i]))
546
+ for i, (path, dist_config) in enumerate(flat_items)
547
+ ]
409
548
 
410
- raise TypeError("Configuration must be a list or dictionary")
549
+ if not preserve_structure:
550
+ return {"_".join(map(str, path)): obj for path, obj in created}
551
+
552
+ result: Dict = {}
553
+ for path, obj in created:
554
+ cursor = result
555
+ for part in path[:-1]:
556
+ cursor = cursor.setdefault(part, {})
557
+ cursor[path[-1]] = obj
558
+ return result
411
559
 
412
560
  @classmethod
413
561
  def _validate_and_create(cls, dist_config, seed):
414
562
  """
415
- Validate that each of the distribution configurations has ONLY
416
- 'class_name' and 'params' keys, add 'random_seed' to params, and
417
- create the distribution instance.
563
+ Validate a distribution config, inject a random seed, and instantiate.
564
+
565
+ Checks that `dist_config` has exactly the keys `'class_name'` and
566
+ `'params'`, injects `random_seed` into the params, then creates
567
+ and returns the distribution instance.
418
568
 
419
569
  Parameters
420
570
  ----------
421
571
  dist_config : dict
422
572
  Dictionary specifying the distribution configuration. Must have
423
- keys 'class_name' (str) and 'params' (dict), and no others.
573
+ keys `'class_name'` (str) and `'params'` (dict), and no others.
424
574
  seed : int
425
575
  The seed to include in the distribution's parameters (as
426
- 'random_seed').
576
+ `'random_seed'`).
427
577
 
428
578
  Returns
429
579
  -------
@@ -451,48 +601,53 @@ class DistributionRegistry:
451
601
 
452
602
  # Copy params and inject the random seed.
453
603
  params = dist_config["params"].copy()
454
- params["random_seed"] = seed
604
+
605
+ # Only inject random seed if class constructor accepts it
606
+ distribution_class = cls.get(dist_config["class_name"])
607
+ sig = inspect.signature(distribution_class.__init__)
608
+ if "random_seed" in sig.parameters:
609
+ params["random_seed"] = seed
455
610
 
456
611
  # Instantiate and return the distribution object.
457
612
  return cls.create(dist_config["class_name"], **params)
458
613
 
459
614
  @classmethod
460
- def get_template(cls, format="json", indent=2):
615
+ def get_template(cls, format: str = "json", indent: int = 2) -> Union[Dict, str]:
461
616
  """
462
617
  Generate a template configuration containing all registered
463
618
  distributions.
464
619
 
465
620
  This helper method creates a template that includes all registered
466
621
  distribution types with appropriate dummy parameters. Users can modify
467
- this template and pass it directly to create_batch() to instantiate
622
+ this template and pass it directly to `create_batch()` to instantiate
468
623
  their distributions.
469
624
 
470
625
  Parameters
471
626
  ----------
472
627
  format : str, default="json"
473
- Output format: 'dict' for Python dictionary or 'json' for JSON
474
- string
628
+ Output format: `'dict'` for a Python dictionary or `'json'`
629
+ for a JSON string.
475
630
  indent : int, default=2
476
- Indentation for JSON formatting (if format='json')
631
+ Indentation for JSON formatting (only used when
632
+ `format='json'`).
477
633
 
478
634
  Returns
479
635
  -------
480
636
  Union[Dict, str]
481
- Either a dictionary (if format='dict') or a JSON string (if
482
- format='json') containing template configurations for all
483
- registered distributions
637
+ Either a dictionary (if`format='dict'`) or a JSON string (if
638
+ `format='json'`) containing template configurations for all
639
+ registered distributions.
484
640
 
485
641
  Examples
486
642
  --------
487
643
  >>> template = DistributionRegistry.get_template(format='dict')
488
- >>> print(template.keys())
489
- dict_keys(['Exponential_example', 'Normal_example',
490
- 'Uniform_example', ...])
644
+ >>> print(list(template.keys()))
645
+ ['Exponential_example', 'Normal_example', 'Uniform_example', ...]
491
646
 
492
647
  >>> template = DistributionRegistry.get_template(format='json')
493
648
  >>> print(template[:70])
494
649
  {
495
- "Exponential_example": {
650
+ "Exponential_example": {
496
651
  "class_name": "Exponential",
497
652
  "params": {
498
653
  """
@@ -1080,18 +1235,32 @@ class CombinationDistribution:
1080
1235
  sample a combination of values from multiple distributions.
1081
1236
  """
1082
1237
 
1083
- def __init__(self, *dists: Distribution):
1238
+ def __init__(self, *args: Distribution, dists=None):
1084
1239
  """
1085
- Initialize a combination distribution.
1240
+ Initialise a combination distribution.
1241
+
1242
+ Distributions can be passed either as positional arguments or via the
1243
+ `dists` keyword argument, but not both. The keyword form is required
1244
+ when creating instances through the `DistributionRegistry` class,
1245
+ which passes parameters by name.
1086
1246
 
1087
1247
  Parameters
1088
1248
  ----------
1089
- *dists : Sequence[Distribution]
1090
- Variable length sequence of Distribution objects to combine.
1091
- The sample method will return the sum of samples from all these
1092
- distributions.
1093
- """
1094
- self.dists = dists
1249
+ *args : Distribution
1250
+ Distribution objects to combine, passed as positional arguments.
1251
+ E.g. `CombinationDistribution(d1, d2)`.
1252
+ Cannot be used together with `dists`.
1253
+ dists : Sequence[Distribution], optional
1254
+ Distribution objects to combine, passed as a keyword argument.
1255
+ E.g. `CombinationDistribution(dists=[d1, d2])`.
1256
+ Cannot be used together with `*args`.
1257
+ """
1258
+ if args and dists is not None:
1259
+ raise ValueError(
1260
+ "Pass distributions either as positional arguments or as "
1261
+ "'dists', not both."
1262
+ )
1263
+ self.dists = dists if dists is not None else args
1095
1264
 
1096
1265
  def __repr__(self):
1097
1266
  dist_reprs = [repr(dist) for dist in self.dists]
@@ -2195,7 +2364,7 @@ class DiscreteEmpirical:
2195
2364
  """
2196
2365
 
2197
2366
  # convert to array first
2198
- self.values = np.asarray(values)
2367
+ self.values = np.asarray(list(values))
2199
2368
  self.freq = np.asarray(freq)
2200
2369
 
2201
2370
  validate(self.freq, "freq", is_positive_array)
@@ -2244,7 +2413,6 @@ class DiscreteEmpirical:
2244
2413
  - A numpy array of values with shape determined by size parameter
2245
2414
  """
2246
2415
  sample = self.rng.choice(self.values, p=self.probabilities, size=size)
2247
-
2248
2416
  if size is None:
2249
2417
  return sample.item()
2250
2418
  return sample
@@ -10,7 +10,11 @@ import pandas as pd
10
10
  import matplotlib.pyplot as plt
11
11
  import itertools
12
12
 
13
+ from sim_tools.distributions import DistributionRegistry
14
+
15
+
13
16
  # pylint: disable=too-few-public-methods
17
+ @DistributionRegistry.register()
14
18
  class NSPPThinning:
15
19
  """
16
20
  Non Stationary Poisson Process via Thinning.
File without changes
File without changes
File without changes
File without changes
File without changes