I’ve ran in the past into a few issues with how the Average Cycle Time is calculated in the Cycle Time Widget, so I decided to analyze it with Python to see if I find any way to calculate the Average Cycle Time and get the same value displayed in the Cycle Time Widget.
My issue is that I can´t reach the same value for Average Cycle Time that is shown in the Cycle Time Widget.
Can you guys help me figure out this?
At the time of writing this topic, the value for Average Cycle Time that is shown in the Cycle Time Widget is 12 days.
However, using Python, Pandas and connecting with the Feed OData API, I never reach the same value. I’ve reach the value of 11 days, and using other calculation methods, like moving averages, I’ve reached either 9 days or 11 days. I’ve provided the code in my post.
When using Power BI and connecting with the Feed OData API, the way that is instructed here, the value I get for Average Cycle Time is still 11.
At the time of writing this topic, the value I was targeting were 12 days.
Here’s my code - originally written in a Jupyter notebook:
# %%
import pandas as pd
import requests
import json
import base64
import math
from datetime import datetime, timedelta
# %%
token_do_azure = '{hidden}'
pat_encoded = base64.b64encode((":" + token_do_azure).encode()).decode()
headers = {
'Content-Type': 'application/json',
'Authorization': f'Basic {pat_encoded}'
}
# %%
hoje = datetime.today()
delta = timedelta(days=90)
dia_resultante = hoje - delta
dia_formatado = dia_resultante.strftime('%Y-%m-%dT00:00:00.00000Z')
print(dia_formatado)
url = rf"https://analytics.dev.azure.com/{hidden}/_odata/v4.0-preview/WorkItems?$select=WorkItemId,WorkItemType,Title,CycleTimeDays,ClosedDate&$filter=(Project/ProjectName eq 'Suporte_Torres' AND (WorkItemType eq 'Issue') AND State eq 'Done' AND ClosedOn/Date ge {dia_formatado})"
# %%
req = requests.get(url, headers=headers)
req_tabela = json.loads(req.text)
req_valores = req_tabela["value"]
# %%
df = pd.DataFrame(req_valores)
df['ClosedDate'] = pd.to_datetime(df['ClosedDate'], format='ISO8601').dt.date
# %%
print(round(df['CycleTimeDays'].mean(), 0))
# Returns 11.0, instead of 12.0.
# %%
# The moving average part of the code was written based on the moving average content of this page: https://learn.microsoft.com/en-us/azure/devops/report/dashboards/cycle-time-and-lead-time?view=azure-devops
def calcular_janela_n(n_dias):
n = int(0.2 * n_dias)
n = math.floor(n)
if n % 2 == 0:
n -= 1
if n < 1:
n = 1
return n
janela_n = calcular_janela_n(90)
# %%
df['SMA_n'] = df['CycleTimeDays'].rolling(window=janela_n, min_periods=1).mean()
print(round(df['SMA_n'].tail(1).iloc[0], 0))
# Returns 9.0, instead of 12.0.
print(round(df['SMA_n'].mean(), 0))
# Returns 11.0, instead of 12.0.
I attempt with the OData query like as below, and it can return the same CycleTimeDays
as that on the Cycle Time Widget.
https://analytics.dev.azure.com/myOrg/myProj/_odata/v4.0-preview/WorkItems?
$filter=WorkItemType eq 'Task'
and StateCategory eq 'Completed'
and CompletedDate ge 2024-01-20Z
and startswith(Area/AreaPath,'myProj\myArea')
&$select=WorkItemId,WorkItemType,Title,State,ClosedDate,CycleTimeDays,LeadTimeDays
&$expand=Iteration($select=IterationPath),Area($select=AreaPath)
The values of CycleTimeDays
returned by the OData query are float instead of integer. The values displayed on the Cycle Time Widget are generally rounded.
EDIT:
Below are the OData queries I catch from the browser Network trace logs (Developer tools
) when I configure the Cycle Time Widget with the same settings as yours.
Query #1
:
https://analytics.dev.azure.com/{organizationName}/{ProjectName}/_odata/v4.0-preview/WorkItems?
$apply=filter(CompletedDateSK ge 20240618 and Teams/any(t:(t/TeamSK eq {teamId} and (WorkItemType eq 'Issue'))))/groupby((CompletedDateSK, WorkItemType),aggregate($count as CompletedCount,CycleTimeDays with sum as Sum,CycleTimeDays mul CycleTimeDays with sum as SumOfSquares))
Example of the response.
{
"@odata.context": "https://analytics.dev.azure.com/{organizationName}/{ProjectId}/_odata/v4.0-preview/$metadata#WorkItems(CompletedDateSK,WorkItemType,CompletedCount,Sum,SumOfSquares)",
"value": [
{
"@odata.id": null,
"WorkItemType": "Issue",
"CompletedDateSK": 20240717,
"SumOfSquares": 2394586.3654467259,
"Sum": 2425.1853354,
"CompletedCount": 4
}
]
}
Sum/CompletedCount
".Query #2
: Similar as the first query I posted above last time, it will return a list of cycle time for each completed work items within the specified Time period.
https://analytics.dev.azure.com/{organizationName}/{ProjectName}/_odata/v4.0-preview/WorkItems?
$filter=CompletedDateSK ge 20240623
and (Teams/any(t:t/TeamSK eq {teamId}))
and WorkItemType eq 'Issue'
&$select=WorkItemId,Title,WorkItemType,CompletedDateSK,CycleTimeDays
EDIT_2:
According to the statement about "Days on average" on the documentation "Configure Lead/Cycle time widget":
Days on average (average lead time or cycle time) for the main work item types configured for the chart. This number might not be equal to the average cycle/lead time of all work items. It depends on configurations used for widgets. The average number is calculated based on each day the team takes time for work item.
The average days
is calculated based on the working days that do not contain the weekend days, so the sum days
also should be calculated based on the working days.
I think, when setting the days of Rolling period
as 30
, the widget would calculate the sum days
and average days
within the last 30 working days (not contain weekend days).
So, on above Query #1
, the actual date value of CompletedDateSK
could be more than 30 days.