value-stream 0.1.1__py3-none-any.whl
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.
- value_stream/__init__.py +1 -0
- value_stream/app/SPEC.md +79 -0
- value_stream/app/__init__.py +1 -0
- value_stream/app/__main__.py +28 -0
- value_stream/app/contracts.py +18 -0
- value_stream/app/coordinator.py +359 -0
- value_stream/app/errors.py +17 -0
- value_stream/app/exports.py +142 -0
- value_stream/app/gateway.py +130 -0
- value_stream/app/generation.py +269 -0
- value_stream/app/insights.py +75 -0
- value_stream/app/metrics.py +132 -0
- value_stream/app/openapi.json +3699 -0
- value_stream/app/schemas.py +288 -0
- value_stream/app/server.py +298 -0
- value_stream/app/settings.py +46 -0
- value_stream/app/storage.py +651 -0
- value_stream/client/__init__.py +3 -0
- value_stream/client/simulation_runner.py +83 -0
- value_stream/client/views/__init__.py +4 -0
- value_stream/client/views/metadata_viewer.py +235 -0
- value_stream/client/views/result_viewer.py +76 -0
- value_stream/client/views/viewer.py +8 -0
- value_stream/client/web/__init__.py +10 -0
- value_stream/client/web/client.py +115 -0
- value_stream/client/web/errors.py +29 -0
- value_stream/client/web/local_service.py +92 -0
- value_stream/factory/__init__.py +5 -0
- value_stream/factory/developer_factory.py +33 -0
- value_stream/factory/factory.py +43 -0
- value_stream/factory/task_factory.py +64 -0
- value_stream/factory/task_generator.py +102 -0
- value_stream/policy/__init__.py +3 -0
- value_stream/policy/simulation_policy.py +20 -0
- value_stream/resources/__init__.py +23 -0
- value_stream/resources/developer.py +53 -0
- value_stream/resources/qa_tester.py +51 -0
- value_stream/resources/resource.py +162 -0
- value_stream/resources/resource_metadata.py +24 -0
- value_stream/resources/resource_policy.py +13 -0
- value_stream/resources/resource_pool.py +39 -0
- value_stream/resources/resource_tracker.py +160 -0
- value_stream/resources/toolchain.py +43 -0
- value_stream/service/SPEC.md +46 -0
- value_stream/service/__init__.py +1 -0
- value_stream/service/app.py +218 -0
- value_stream/service/codec.py +237 -0
- value_stream/service/job_store.py +322 -0
- value_stream/service/openapi.json +1283 -0
- value_stream/service/scheduler.py +234 -0
- value_stream/service/schemas.py +248 -0
- value_stream/service/settings.py +37 -0
- value_stream/service/worker.py +82 -0
- value_stream/simulation/__init__.py +16 -0
- value_stream/simulation/default_simulation_policy.py +68 -0
- value_stream/simulation/model.py +30 -0
- value_stream/simulation/model_factory.py +54 -0
- value_stream/simulation/simulation.py +144 -0
- value_stream/simulation/simulation_metadata.py +12 -0
- value_stream/simulation/simulation_result.py +20 -0
- value_stream/task/__init__.py +23 -0
- value_stream/task/epoch.py +21 -0
- value_stream/task/event_status.py +8 -0
- value_stream/task/task.py +260 -0
- value_stream/task/task_event.py +97 -0
- value_stream/task/task_history.py +208 -0
- value_stream/task/task_router.py +70 -0
- value_stream/task/task_state.py +7 -0
- value_stream/task/task_type.py +8 -0
- value_stream/workflow/__init__.py +18 -0
- value_stream/workflow/assignment_strategy.py +10 -0
- value_stream/workflow/pool_manager.py +136 -0
- value_stream/workflow/resource_operator.py +204 -0
- value_stream/workflow/sdlc_workflow.py +99 -0
- value_stream/workflow/support_workflow.py +23 -0
- value_stream/workflow/task_store.py +125 -0
- value_stream/workflow/workflow_policy.py +12 -0
- value_stream-0.1.1.dist-info/METADATA +288 -0
- value_stream-0.1.1.dist-info/RECORD +81 -0
- value_stream-0.1.1.dist-info/WHEEL +4 -0
- value_stream-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from value_stream.resources import Developer
|
|
3
|
+
|
|
4
|
+
from .factory import Factory
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DeveloperFactory(Factory):
|
|
8
|
+
"""Utility for creating Developer objects"""
|
|
9
|
+
|
|
10
|
+
@classmethod
|
|
11
|
+
def create(cls, count: int, **kwargs: Any) -> list[Developer]:
|
|
12
|
+
"""Creates Developer objects based on DeveloperFactory configuration.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
count (int): number of developers to create
|
|
16
|
+
**kwargs (Any): Kwargs.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
if count <= 0:
|
|
20
|
+
raise ValueError("count must be > 0")
|
|
21
|
+
|
|
22
|
+
developers: list[Developer] = []
|
|
23
|
+
|
|
24
|
+
for i in range(count):
|
|
25
|
+
args = cls._generate_args(**kwargs)
|
|
26
|
+
|
|
27
|
+
developers.append(Developer(name=cls._developer_name(i), **args))
|
|
28
|
+
|
|
29
|
+
return developers
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def _developer_name(cls, index: int) -> str:
|
|
33
|
+
return f"Developer {index}"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from typing import Any, Generator
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Factory:
|
|
6
|
+
"""Provides shared helpers for creating configured simulation objects."""
|
|
7
|
+
|
|
8
|
+
_rng = np.random.default_rng()
|
|
9
|
+
|
|
10
|
+
@classmethod
|
|
11
|
+
def uniform(cls, low: float, high: float) -> Generator[float, Any, None]:
|
|
12
|
+
"""Yield samples from a uniform distribution.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
low (float): Lower bound of the range.
|
|
16
|
+
high (float): Upper bound of the range.
|
|
17
|
+
|
|
18
|
+
Yields:
|
|
19
|
+
float: Successive random samples.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
while True:
|
|
23
|
+
yield cls._rng.uniform(low, high)
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def _generate_args(cls, **kwargs: Any) -> dict[str, Any]:
|
|
27
|
+
"""Resolve generator-valued arguments to their next values.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
**kwargs (Any): Values to pass to a factory-created object.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
dict[str, Any]: Arguments with generator values sampled once.
|
|
34
|
+
"""
|
|
35
|
+
args = {}
|
|
36
|
+
|
|
37
|
+
for k, v in kwargs.items():
|
|
38
|
+
if isinstance(v, Generator):
|
|
39
|
+
args[k] = next(v)
|
|
40
|
+
else:
|
|
41
|
+
args[k] = v
|
|
42
|
+
|
|
43
|
+
return args
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from typing import Any, Type, Optional
|
|
2
|
+
import random
|
|
3
|
+
|
|
4
|
+
from simpy import Environment
|
|
5
|
+
|
|
6
|
+
from value_stream.task import Task
|
|
7
|
+
|
|
8
|
+
from .factory import Factory
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TaskFactory(Factory):
|
|
12
|
+
"""Utility for creating Tasks"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, cls: Type[Task] = Task, **task_kwargs: Any) -> None:
|
|
15
|
+
"""Initializes a TaskFactory.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
**task_kwargs (Any): Default task constructor arguments.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
self._task_kwargs = task_kwargs
|
|
22
|
+
self.cls = cls
|
|
23
|
+
|
|
24
|
+
def create(
|
|
25
|
+
self, count: int, env: Optional[Environment] = None, shuffle: bool = True
|
|
26
|
+
) -> list[Task]:
|
|
27
|
+
"""Creates Task objects based on TaskFactory configuration.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
count (int): Number of objects to create.
|
|
31
|
+
env (Optional[Environment]): Simulation environment.
|
|
32
|
+
shuffle (bool): Whether to randomize the created task order.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ValueError: If count is not positive or environment time conflicts with creation_sim_t.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
list[Task]: Tasks created with this factory configuration.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
if count <= 0:
|
|
42
|
+
raise ValueError("count must be > 0")
|
|
43
|
+
|
|
44
|
+
if (env is not None) and ("creation_sim_t" in self._task_kwargs):
|
|
45
|
+
raise ValueError("env and creation_sim_t are mutually exclusive")
|
|
46
|
+
|
|
47
|
+
tasks: list[Task] = []
|
|
48
|
+
|
|
49
|
+
for i in range(count):
|
|
50
|
+
|
|
51
|
+
args = self._generate_args(**self._task_kwargs)
|
|
52
|
+
|
|
53
|
+
if "task_name" not in args:
|
|
54
|
+
args["task_name"] = f"{i+1}"
|
|
55
|
+
|
|
56
|
+
if env is not None:
|
|
57
|
+
args["creation_sim_t"] = env.now
|
|
58
|
+
|
|
59
|
+
tasks.append(self.cls(**args))
|
|
60
|
+
|
|
61
|
+
if shuffle is True:
|
|
62
|
+
random.shuffle(tasks)
|
|
63
|
+
|
|
64
|
+
return tasks
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from simpy import Environment, Interrupt, Process, Store
|
|
4
|
+
from value_stream.task import Task
|
|
5
|
+
from .task_factory import TaskFactory
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TaskGenerator:
|
|
9
|
+
"""creates a set of Tasks at a regular simulation time interval.
|
|
10
|
+
Useful for simulating task creation over time and the creation of unexpected toil.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self, factory: TaskFactory, group_size: int = 1, limit: Optional[int] = None
|
|
15
|
+
) -> None:
|
|
16
|
+
"""Configure task generation and its output limit.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
factory (TaskFactory): Task factory used to create tasks.
|
|
20
|
+
group_size (int): Number of tasks generated in each group.
|
|
21
|
+
limit (Optional[int]): Maximum number of items allowed.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
self.group_size = group_size
|
|
25
|
+
self.factory = factory
|
|
26
|
+
|
|
27
|
+
self.proc: Optional[Process] = None
|
|
28
|
+
self._batch_num = 0
|
|
29
|
+
self.limit = limit
|
|
30
|
+
|
|
31
|
+
def _get_tasks(self, env: Environment) -> list[Task]:
|
|
32
|
+
"""Generate the next group of tasks.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
env (Environment): Simulation environment.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
list[Task]: The generated task group.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
self._batch_num += 1
|
|
42
|
+
serial_num = 0
|
|
43
|
+
|
|
44
|
+
tasks = self.factory.create(env=env, count=self.group_size)
|
|
45
|
+
|
|
46
|
+
for task in tasks:
|
|
47
|
+
if self.group_size == 1:
|
|
48
|
+
task.task_name = f"S{self._batch_num}"
|
|
49
|
+
else:
|
|
50
|
+
serial_num += 1
|
|
51
|
+
task.task_name = f"S{self._batch_num}-{serial_num}"
|
|
52
|
+
|
|
53
|
+
return tasks
|
|
54
|
+
|
|
55
|
+
def start(self, env: Environment, target: Store, interval: float) -> None:
|
|
56
|
+
"""Start generating tasks at the requested interval.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
env (Environment): Simulation environment.
|
|
60
|
+
target (Store): Target.
|
|
61
|
+
interval (float): Interval.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError: If the generation interval is not positive.
|
|
65
|
+
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
if interval <= 0:
|
|
69
|
+
raise ValueError("interval must be > 0")
|
|
70
|
+
|
|
71
|
+
self._batch_num = 0
|
|
72
|
+
|
|
73
|
+
def gen(limit: Optional[int]):
|
|
74
|
+
|
|
75
|
+
while True:
|
|
76
|
+
|
|
77
|
+
if limit is not None:
|
|
78
|
+
if limit == 0:
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
limit -= 1
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
|
|
85
|
+
event = env.timeout(delay=interval)
|
|
86
|
+
yield event
|
|
87
|
+
|
|
88
|
+
value: list[Task] = self._get_tasks(env)
|
|
89
|
+
|
|
90
|
+
for v in value:
|
|
91
|
+
yield target.put(v)
|
|
92
|
+
|
|
93
|
+
except Interrupt:
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
self.proc = env.process(gen(self.limit))
|
|
97
|
+
|
|
98
|
+
def stop(self) -> None:
|
|
99
|
+
"""Stop the running operation."""
|
|
100
|
+
|
|
101
|
+
if self.proc is not None:
|
|
102
|
+
self.proc.interrupt()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from abc import abstractmethod
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from value_stream.resources import ResourcePolicy
|
|
5
|
+
from value_stream.task import Task
|
|
6
|
+
from value_stream.workflow import WorkflowPolicy, AssignmentStrategy
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SimulationPolicy(ResourcePolicy, WorkflowPolicy):
|
|
10
|
+
"""encapsulates control logic for a simulation"""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def task_priority(
|
|
14
|
+
self, tasks_1: list[Task], tasks_2: list[Task]
|
|
15
|
+
) -> Literal[0] | Literal[1] | Literal[-1]:
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def support_assignment_strategy(self, tasks: list[Task]) -> AssignmentStrategy:
|
|
20
|
+
pass
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .resource import Resource
|
|
2
|
+
from .resource_metadata import ResourceMetadata
|
|
3
|
+
from .resource_tracker import InMemoryResourceTracker, NoOpResourceTracker, ResourceTracker
|
|
4
|
+
from .resource_policy import ResourcePolicy
|
|
5
|
+
from .resource_pool import ResourcePool
|
|
6
|
+
from .resource_pool import PooledResource
|
|
7
|
+
from .developer import Developer
|
|
8
|
+
from .qa_tester import QATester
|
|
9
|
+
from .toolchain import Toolchain
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Resource",
|
|
13
|
+
"InMemoryResourceTracker",
|
|
14
|
+
"Developer",
|
|
15
|
+
"NoOpResourceTracker",
|
|
16
|
+
"QATester",
|
|
17
|
+
"PooledResource",
|
|
18
|
+
"ResourcePolicy",
|
|
19
|
+
"ResourcePool",
|
|
20
|
+
"ResourceTracker",
|
|
21
|
+
"Toolchain",
|
|
22
|
+
"ResourceMetadata",
|
|
23
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from typing import Any, Generator
|
|
2
|
+
|
|
3
|
+
from pydantic import Field, FiniteFloat
|
|
4
|
+
from simpy import Environment, Interrupt, Timeout
|
|
5
|
+
|
|
6
|
+
from value_stream.task import EventStatus, Task
|
|
7
|
+
|
|
8
|
+
from .resource import Resource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Developer(Resource):
|
|
12
|
+
"""Simulates actions perform on a task by a software developer"""
|
|
13
|
+
|
|
14
|
+
efficiency: FiniteFloat = Field(default=1.0, gt=0)
|
|
15
|
+
name: str = ""
|
|
16
|
+
|
|
17
|
+
def effort(self, tasks: list[Task]) -> float:
|
|
18
|
+
"""Return the developer effort for the supplied tasks.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
tasks (list[Task]): Tasks to process.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
float: The developer effort assigned to the supplied tasks.
|
|
25
|
+
"""
|
|
26
|
+
return sum([task.remaining_work() / self.efficiency for task in tasks])
|
|
27
|
+
|
|
28
|
+
def do_work(self, env: Environment, tasks: list[Task]) -> Generator[Timeout, Any, None]:
|
|
29
|
+
"""Apply developer effort to tasks and yield until the work completes.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
env (Environment): Simulation environment.
|
|
33
|
+
tasks (list[Task]): Tasks to process.
|
|
34
|
+
|
|
35
|
+
Yields:
|
|
36
|
+
Timeout: Event that completes developer work.
|
|
37
|
+
"""
|
|
38
|
+
start = env.now
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
yield env.timeout(self.effort(tasks), value={"result": EventStatus.SUCCESS})
|
|
42
|
+
|
|
43
|
+
except Interrupt:
|
|
44
|
+
applied_story_points = (env.now - start) * self.efficiency
|
|
45
|
+
for task in tasks:
|
|
46
|
+
applied_story_points = task.do_work(applied_story_points)
|
|
47
|
+
|
|
48
|
+
raise
|
|
49
|
+
|
|
50
|
+
applied_story_points = (env.now - start) * self.efficiency
|
|
51
|
+
|
|
52
|
+
for task in tasks:
|
|
53
|
+
applied_story_points = task.do_work(applied_story_points)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from typing import Any, Generator
|
|
2
|
+
import random
|
|
3
|
+
from pydantic import FiniteFloat, Field
|
|
4
|
+
from simpy import Environment, Timeout
|
|
5
|
+
|
|
6
|
+
from value_stream.task import EventStatus, Task
|
|
7
|
+
|
|
8
|
+
from .resource import Resource
|
|
9
|
+
from .resource_pool import PooledResource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class QATester(Resource, PooledResource):
|
|
13
|
+
"""Tests completed development tasks and reports pass or failure."""
|
|
14
|
+
|
|
15
|
+
time_cost: FiniteFloat = Field(default=0.1)
|
|
16
|
+
failure_rate: FiniteFloat = Field(default=0.0, ge=0, le=1)
|
|
17
|
+
failure_cost: FiniteFloat = Field(default=0.0, ge=0, le=1)
|
|
18
|
+
|
|
19
|
+
def do_work(
|
|
20
|
+
self, env: Environment, tasks: list[Task]
|
|
21
|
+
) -> Generator[Timeout, Any, dict[str, EventStatus]]:
|
|
22
|
+
"""Test tasks and yield until the QA work completes.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
env (Environment): Simulation environment.
|
|
26
|
+
tasks (list[Task]): Tasks to process.
|
|
27
|
+
|
|
28
|
+
Yields:
|
|
29
|
+
Timeout: Event that completes QA work.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
dict[str, EventStatus]: Work result and completion status.
|
|
33
|
+
"""
|
|
34
|
+
effort = sum(task.story_points * self.time_cost for task in tasks)
|
|
35
|
+
|
|
36
|
+
if random.random() < self.failure_rate:
|
|
37
|
+
result = EventStatus.FAILURE
|
|
38
|
+
else:
|
|
39
|
+
result = EventStatus.SUCCESS
|
|
40
|
+
|
|
41
|
+
work = env.timeout(effort, value={"result": result})
|
|
42
|
+
|
|
43
|
+
yield work
|
|
44
|
+
|
|
45
|
+
# Cost of remediation is a perceentage of original effort
|
|
46
|
+
if result == EventStatus.FAILURE:
|
|
47
|
+
for task in tasks:
|
|
48
|
+
remediation_work = task.story_points * self.failure_cost
|
|
49
|
+
task.do_work(-remediation_work)
|
|
50
|
+
|
|
51
|
+
return work.value
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# Parent class for Developer and Toolchain
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Any, Generator, Optional, ClassVar
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, PrivateAttr
|
|
7
|
+
from simpy import Environment, Event, Interrupt, Process, Timeout
|
|
8
|
+
from simpy.events import ProcessGenerator
|
|
9
|
+
|
|
10
|
+
from value_stream.task import TaskState
|
|
11
|
+
from value_stream.task import EventStatus, Task, TaskRouter
|
|
12
|
+
|
|
13
|
+
from .resource_policy import ResourcePolicy
|
|
14
|
+
from .resource_tracker import ResourceTracker
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Resource(ABC, BaseModel):
|
|
18
|
+
"""Base class for simulation objects that operate on tasks"""
|
|
19
|
+
|
|
20
|
+
class ProcessWrapper(Process):
|
|
21
|
+
"""SimPy process that retains the tasks being processed."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, env: Environment, tasks: list[Task], generator: ProcessGenerator):
|
|
24
|
+
super().__init__(env, generator)
|
|
25
|
+
self.tasks = tasks
|
|
26
|
+
|
|
27
|
+
MAX_BACKLOG: ClassVar[int] = 1000
|
|
28
|
+
|
|
29
|
+
_idle_t: float = 0
|
|
30
|
+
_id: uuid.UUID = PrivateAttr(default_factory=uuid.uuid4)
|
|
31
|
+
_process: Optional[ProcessWrapper] = None
|
|
32
|
+
_suspended_work: list[Event] = []
|
|
33
|
+
|
|
34
|
+
def __hash__(self) -> int:
|
|
35
|
+
return self._id.__hash__()
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def resource_id(self) -> uuid.UUID:
|
|
39
|
+
return self._id
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def idle_t(self):
|
|
43
|
+
return self._idle_t
|
|
44
|
+
|
|
45
|
+
@idle_t.setter
|
|
46
|
+
def idle_t(self, idle_t: float):
|
|
47
|
+
self._idle_t = idle_t
|
|
48
|
+
|
|
49
|
+
def operate(
|
|
50
|
+
self,
|
|
51
|
+
env: Environment,
|
|
52
|
+
tasks: list[Task],
|
|
53
|
+
task_router: TaskRouter,
|
|
54
|
+
policy: ResourcePolicy,
|
|
55
|
+
workflow_state: TaskState,
|
|
56
|
+
tracker: ResourceTracker,
|
|
57
|
+
) -> Generator[Event, Any, None]:
|
|
58
|
+
"""Simulates an action on a task object.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
env (Environment): Simulation environment.
|
|
62
|
+
tasks (list[Task]): Tasks to process.
|
|
63
|
+
task_router (TaskRouter): Router for completed tasks.
|
|
64
|
+
policy (ResourcePolicy): Policy used to make simulation decisions.
|
|
65
|
+
workflow_state (TaskState): Workflow state associated with the operation.
|
|
66
|
+
tracker (ResourceTracker): Tracker for resource activity.
|
|
67
|
+
|
|
68
|
+
Yields:
|
|
69
|
+
Event: SimPy events yielded while processing tasks.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
for task in tasks:
|
|
73
|
+
task.start(env.now, workflow_state, resource_id=self._id)
|
|
74
|
+
|
|
75
|
+
if (self._process is not None) and (not self._process.processed):
|
|
76
|
+
if policy.task_priority(tasks, self._process.tasks) == -1:
|
|
77
|
+
self._process.interrupt()
|
|
78
|
+
else:
|
|
79
|
+
# if what's in progress is of higher priority, queue up current work
|
|
80
|
+
# yield self._process ....
|
|
81
|
+
yield env.process(self._pause(env))
|
|
82
|
+
|
|
83
|
+
while True:
|
|
84
|
+
status = EventStatus.SUCCESS
|
|
85
|
+
self._process = self._create_process(env, tasks)
|
|
86
|
+
|
|
87
|
+
start_t = env.now
|
|
88
|
+
try:
|
|
89
|
+
|
|
90
|
+
tracker.start_work(workflow_state, env.now - self._idle_t)
|
|
91
|
+
yield self._process
|
|
92
|
+
|
|
93
|
+
if self._process.value is not None:
|
|
94
|
+
status = self._process.value["result"]
|
|
95
|
+
|
|
96
|
+
except Interrupt:
|
|
97
|
+
|
|
98
|
+
# if do_work is interrupted, wait on a signal that will
|
|
99
|
+
# be triggered once this resource has processed all
|
|
100
|
+
# subsequent interruptions
|
|
101
|
+
|
|
102
|
+
interruption_start_t = env.now
|
|
103
|
+
|
|
104
|
+
tracker.complete_work(workflow_state, status, elapsed_t=env.now - start_t)
|
|
105
|
+
|
|
106
|
+
yield env.process(self._pause(env))
|
|
107
|
+
|
|
108
|
+
tracker.interruption(workflow_state, elapsed_t=env.now - interruption_start_t)
|
|
109
|
+
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
tracker.complete_work(workflow_state, status, elapsed_t=env.now - start_t)
|
|
113
|
+
self._idle_t = env.now
|
|
114
|
+
|
|
115
|
+
for task in tasks:
|
|
116
|
+
task.end(env.now, workflow_state, status=status, resource_id=self._id)
|
|
117
|
+
|
|
118
|
+
if status == EventStatus.FAILURE:
|
|
119
|
+
yield task_router.route(task=task.as_rework(), status=status)
|
|
120
|
+
else:
|
|
121
|
+
yield task_router.route(task=task.clear_rework(), status=status)
|
|
122
|
+
|
|
123
|
+
# once work is complete, check for previously interrupted work
|
|
124
|
+
# and trigger resumption
|
|
125
|
+
if self._suspended_work:
|
|
126
|
+
signal = self._suspended_work[-1]
|
|
127
|
+
signal.succeed()
|
|
128
|
+
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
@abstractmethod
|
|
132
|
+
def do_work(self, env: Environment, tasks: list[Task]) -> Generator[Timeout, Any, None]:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
def _pause(self, env: Environment) -> Generator[Event, Any, None]:
|
|
136
|
+
"""Wait until interrupted work can resume.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
env (Environment): Simulation environment.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
RuntimeError: If the number of suspended work signals exceeds the backlog limit.
|
|
143
|
+
|
|
144
|
+
Yields:
|
|
145
|
+
Event: SimPy events yielded while waiting for work to resume.
|
|
146
|
+
"""
|
|
147
|
+
while True:
|
|
148
|
+
signal = env.event()
|
|
149
|
+
self._suspended_work.append(signal)
|
|
150
|
+
|
|
151
|
+
if len(self._suspended_work) > self.MAX_BACKLOG:
|
|
152
|
+
raise RuntimeError("Runaway simulation detected and aborted")
|
|
153
|
+
|
|
154
|
+
yield signal
|
|
155
|
+
|
|
156
|
+
self._suspended_work.pop()
|
|
157
|
+
|
|
158
|
+
if (self._process is not None) and (self._process.target is None):
|
|
159
|
+
break
|
|
160
|
+
|
|
161
|
+
def _create_process(self, env: Environment, tasks: list[Task]):
|
|
162
|
+
return Resource.ProcessWrapper(env, tasks, self.do_work(env, tasks))
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from pydantic import BaseModel, ConfigDict, field_serializer
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from value_stream.task import TaskState
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ResourceMetadata(BaseModel):
|
|
7
|
+
"""Stores metadata collected for one simulation resource."""
|
|
8
|
+
|
|
9
|
+
model_config = ConfigDict(use_enum_values=True)
|
|
10
|
+
|
|
11
|
+
time: float
|
|
12
|
+
state: TaskState
|
|
13
|
+
waiting: int = 0
|
|
14
|
+
allocated: int = 0
|
|
15
|
+
active: int = 0
|
|
16
|
+
success_t: Optional[float] = None
|
|
17
|
+
failure_t: Optional[float] = None
|
|
18
|
+
interruption_t: Optional[float] = None
|
|
19
|
+
waiting_t: Optional[float] = None
|
|
20
|
+
idle_t: Optional[float] = None
|
|
21
|
+
|
|
22
|
+
@field_serializer("state")
|
|
23
|
+
def serialize_event(self, state: TaskState):
|
|
24
|
+
return state.value
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Literal
|
|
3
|
+
from value_stream.task import Task
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ResourcePolicy(ABC):
|
|
7
|
+
"""Defines resource-level decisions during task processing."""
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def task_priority(
|
|
11
|
+
self, tasks_1: list[Task], tasks_2: list[Task]
|
|
12
|
+
) -> Literal[0] | Literal[1] | Literal[-1]:
|
|
13
|
+
pass
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from typing import Type, Optional, Self
|
|
2
|
+
import uuid
|
|
3
|
+
from pydantic import BaseModel, Field, PrivateAttr, ConfigDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ResourcePool(BaseModel):
|
|
7
|
+
"""Generates a fixed or unlimited quantity of a homogeneous resource"""
|
|
8
|
+
|
|
9
|
+
model_config = ConfigDict(extra="allow")
|
|
10
|
+
class_name: Type = Field()
|
|
11
|
+
limit: Optional[int] = Field(default=None, gt=0)
|
|
12
|
+
_i: int = PrivateAttr(default=0, init=False)
|
|
13
|
+
|
|
14
|
+
_pool_id: uuid.UUID = PrivateAttr(default_factory=uuid.uuid4)
|
|
15
|
+
|
|
16
|
+
def __next__(self):
|
|
17
|
+
|
|
18
|
+
if self.limit is None:
|
|
19
|
+
kwargs = {} if not self.model_extra else self.model_extra
|
|
20
|
+
return self.class_name(**kwargs)
|
|
21
|
+
|
|
22
|
+
if self._i < self.limit:
|
|
23
|
+
self._i += 1
|
|
24
|
+
kwargs = {} if not self.model_extra else self.model_extra
|
|
25
|
+
return self.class_name(**kwargs)
|
|
26
|
+
|
|
27
|
+
raise StopIteration
|
|
28
|
+
|
|
29
|
+
def __iter__(self) -> Self: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
30
|
+
self._i = 0
|
|
31
|
+
return self
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PooledResource:
|
|
35
|
+
"""Tracks whether a resource is available to the pool."""
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def create_pool(cls, limit: Optional[int] = None, **kwargs):
|
|
39
|
+
return ResourcePool(class_name=cls, limit=limit, **kwargs)
|