Чи все так погано зі світлом — перевіряємо даними і графіками
Усім привіт, мене звати Євген Ковалевський, і я працюю CPO/CTO в TECHIIA та KOLO. Я вирішив за допомогою звичних мені інструментів і коду перевірити, чи справді така невтішна ситуація зі світлом у моєму районі столиці і наскільки можна довіряти графікам відключення електроенергії.
Русня дістала вже всіх. І їхні обстріли нашої інфраструктури дуже скоро будуть відплачені їм. Але допоки наші воїни працюють над відплатою — під кожним постом про відключення світла набігає цілий натовп наших земляків, які шукають когось винного, окрім нашого безпосереднього ворога. З цієї ж серії — і написання скарг, що сусіду мають вимкнути світло, щоб усе було справедливо.
Дивлячись на це все — виникло бажання перевірити цифрами, а чи дійсно все ТАК погано, і куди це все рухається.
Дисклеймер: В коді є неоптимальні місця, але їх «вичісувати» я не буду, бо вирішив витратити цей час на те, щоб написати цей пост, з надією що комусь може бути корисним або хоча б просто цікавим, як можна звичайний набір однотипних повідомлень в чаті перетворити в корисну аналітику.
Графіки краще за емоції
Завжди любив візуалізувати дані, бо так все стає набагато зрозуміліше. І от, знайшовши чат, в якому є повідомлення про відключення і повернення світла в ЖК «Славутич», і перевіривши, що в нього близька до 100% кореляція з ще парою ЖК на Позняках («Патріотика», «Зарічний» як мінімум) я не втримався, щоб не дістати Jupyter Notebook, Pandas i Numpy. І, поїхало.
Шкода тільки, що повідомлення там є тільки від 10 листопада. Але це вже щось.
Готуємо дані
Для початку — вивантажуємо всі повідомлення з каналу в JSON-файл і розкладаємо його в датасет. На основі повідомлень вирахуємо статус світла.
Надалі у нас 1 — буде відповідати за наявність світла, а 0 — за його відсутність.
import pandas as pd
import numpy as np
#%%
def get_state(text):
#визначаємо зміну статусу на основі тексту
if '❌' in text:
return 0
elif '✅' in text:
return 1
else:
return None
raw_json = pd.read_json('result.json')
json_df = pd.json_normalize(raw_json['messages']) #розкладаємо словник messages в стовпчики
json_df.date = pd.to_datetime(json_df.date)
json_df.set_index('date', inplace=True)
json_df['state'] = [get_state(x) for x in json_df.text] #вираховуємо стейт на основі тексту
json_df = json_df[['state']].dropna(subset = ['state']) #видаляємо повідомлення які не про зміну стейту
json_df = json_df.groupby(pd.Grouper(level='date', freq='T')).max() #заповнюємо індекс відмітками кожної хвилини
json_df = json_df.fillna(method="ffill") #заливаємо точки з пустими значеннями найбільш актуальним станом
json_df = json_df.loc['2022-11-11':'2022-12-16'] #прибираємо неповні дні. TODO: зробити динамічним на основі датасету
json_df.head()
І матимемо на виході:
date state 2022-11-11 00:00:00 1.0 2022-11-11 00:01:00 1.0 2022-11-11 00:02:00 1.0 2022-11-11 00:03:00 1.0 2022-11-11 00:04:00 1.0 ...
Починаємо малювати
Відразу подивимось, як це виглядає:
%matplotlib inline
import matplotlib.pyplot as plt
import seaborn; seaborn.set()
json_df.asfreq('T').plot(figsize=(15, 5))

Користі з такої візуалізації доволі мало. Якщо згрупувати за днями — має стати краще.
Для обрахунку статусу при групуванні візьмемо середнє значення статусів і одразу отримаємо кількість світла на день у відсотках, що доволі зручно.
df = json_df.groupby(pd.Grouper(level='date', freq='D')).mean() #групуємо по дням fig, ax = plt.subplots(figsize=(15,5)) ax.plot(df, label='% per day') ax.set_ylim([0,1.1]) ax.legend(loc=2)

Стало краще. Але все одно, ще не дуже зрозуміло, тому настав час діставати магію цього стеку.
Тепер нормально візуалізуємо
Перемалюємо в bar-chart, додамо на кожен день підпис, підправимо легенду, а зверху ще доповнимо лінією тренду і
fig, ax = plt.subplots(figsize=(15,5))
plt.xticks(rotation = 75)
width = 0.7
p1 = ax.bar(df.index, df.state, width)
ax.set_xticks(df.index)
ax.set_title('% часу зі світлом (Патріотика)')
ax.bar_label(p1, labels=[f'{x:.2%}' for x in df.state],rotation=90,fontsize=10,label_type='center', color='white')
#add trend-line and avg
import matplotlib.dates as mdates
x = mdates.date2num(df.index)
y= df['state']
z = np.polyfit(x, df['state'], 1)
p = np.poly1d(z)
trend_plot = ax.plot(x, p(x), "r--", label='Trend')
df['moving_avg'] = df.rolling(7, center=True, min_periods=1).mean().to_numpy()
moving_plot = ax.plot(df.moving_avg, c='g', label='7d moving AVG')
plt.legend()
plt.show()

Оце вже краще. Якщо взяти, що темп обстрілів буде зберігатись і нема якоїсь критичної точки деградації електросистеми, то на середину березня будемо мати 40% наявності світла на день (~9,5 годин).
Але якщо взяти їх інтенсивність (один раз в
Звісно, ця всі аналітика не враховує зміни погоди і нелінійні деградації мережі, але вже хоч щось.
Додамо графіки відключень
Ця інформація вже корисна, але дуже цікаво і не менш корисно було б подивитись, чи відповідає статус тому, що є в графіках, і наскільки їм можна довіряти в умовах, що останнім часом ми майже весь час в стані аварійних відключень.
Спочатку згенеруємо графік (це графік групи № 3):
#Let's generate outages schedule
def get_schedule_state(d):
schedule = {'Monday': [[0, 3], [9, 12], [18, 21]],
'Tuesday': [[3, 6], [12, 15], [21, 23]],
'Wednesday': [[0,0], [6,9], [15,18]],
'Thursday': [[0, 3], [9, 12], [18, 21]],
'Friday': [[3, 6], [12, 15], [21, 23]],
'Saturday': [[0,0], [6,9], [15,18]],
'Sunday': [[0, 3], [9, 12], [18, 21]]}
for periods in schedule[d.day_name()]:
if periods[0] <= d.hour <= periods[1]:
return 0
return 1
json_df['schedule_state'] = [get_schedule_state(x) for x in json_df.index]
json_df['is_scheduled'] = (json_df['schedule_state'] == json_df['state']).astype(int)
df = json_df.groupby(pd.Grouper(level='date', freq='D')).mean() # згрупуємо з новими даними
І відразу доповнимо минулу візуалізацію:
fig, ax = plt.subplots(figsize=(15,5))
plt.xticks(rotation = 75)
width = 0.7
p1 = ax.bar(df.index, df.state, width, label = 'По факту')
p2 = ax.plot(df.schedule_state, color = 'red', label = 'Розклад')
ax.set_xticks(df.index)
plt.legend()
ax.set_title('% часу зі світлом (Патріотика)')
ax.bar_label(p1, labels=[f'{x:.2%}' for x in df.state],rotation=90,fontsize=10,label_type='center', color='white')
plt.show()

Що цікаво, не дивлячись на емоційне відчуття «світла менше чим обіцяли», — менше від заявленого у графіку його було всього пару днів. Схоже на те, що відчуття у нас формуються від відповідності графіку, а не загальної кількості світла. Треба перевіряти.
Відповідність графіку
Для того, щоб оцінити — сформуємо 4 статуси: світло по графіку, світло поза графіком, без світла по графіку і без світла поза графіком.
# Вирахуємо статуси на основі розкладу json_df['scheduled_on'] = np.where((json_df['state'] == 1) & (json_df['schedule_state'] == 1), 1, 0) json_df['unscheduled_on'] = np.where((json_df['state'] == 1) & (json_df['schedule_state'] == 0), 1, 0) json_df['scheduled_off'] = np.where((json_df['state'] == 0) & (json_df['schedule_state'] == 0), 1, 0) json_df['unscheduled_off'] = np.where((json_df['state'] == 0) & (json_df['schedule_state'] == 1), 1, 0) full_states = json_df.groupby(pd.Grouper(level='date', freq='D')).mean() full_states.mean() # Виведемо тотали
state 0.686806 schedule_state 0.547454 is_scheduled 0.668711 scheduled_on 0.451485 unscheduled_on 0.235320 scheduled_off 0.217226 unscheduled_off 0.095968
Тобто:
було світло: 68,7%
мало бути за графіком: 54,7%
було ЗА графіком: 45,1%
було НЕ за графіком: 23,5%
не було ЗА графіком: 21,7%
не було НЕ за графіком: 9,6%(!)
Значить, світло було поза графіком в 2 рази частіше, чим навпаки. Гляньмо на це за різними днями:
fig, ax = plt.subplots(figsize=(15,5))
plt.xticks(rotation = 75)
width = 0.7
states = ['Scheduled ON', 'Unscheduled ON','Scheduled OFF', 'Unscheduled OFF']
son = ax.bar(full_states.index, full_states.scheduled_on, width, color='green')
uon = ax.bar(full_states.index, full_states.unscheduled_on, width, bottom=full_states.scheduled_on, color='darkgreen')
soff = ax.bar(full_states.index, full_states.scheduled_off, width,
bottom=full_states.unscheduled_on+full_states.scheduled_on, color='orange')
usoff = ax.bar(full_states.index, full_states.unscheduled_off, width,
bottom=full_states.scheduled_off+full_states.unscheduled_on+full_states.scheduled_on, color='red')
ax.set_xticks(full_states.index)
plt.legend(states, bbox_to_anchor=(0,1.02,1,0.2), loc="upper left", mode='expand', ncol=4)
fig.tight_layout()
ax.set_title('% статусів по дням')
plt.show()

По графікам залишилось останнє запитання: наскільки ми взагалі можемо на нього покладатись.
fig, ax = plt.subplots(figsize=(15,5))
plt.xticks(rotation = 75)
width = 0.7
p1 = ax.bar(full_states.index, full_states.is_scheduled, width)
mean = np.nanmean(full_states.is_scheduled)
avg_line = ax.axhline(y=mean, linestyle='--', label=f'Середній: {mean:.2%}', color='blue')
ax.set_xticks(full_states.index)
ax.set_title('% часу з відповідністю графіку')
ax.bar_label(p1, labels=[f'{x:.2%}' for x in full_states.is_scheduled],rotation=90,fontsize=10,label_type='center', color='white')
print(full_states[['state', 'schedule_state']].corr())
plt.legend()
plt.show()

Як бачимо тут у нас доволі напружено все. Середній показник за весь час — 66,87%, а кореляція між фактом і графіком 21,2%.
Нагадаю, що майже весь цей час Київ перебуває в стані аварійних відключень і графіки офіційно не діють. Ну, і наявність світла поза графіком, статистично, також впливає негативно на цей показник.
Коли світла більше
Тоді дізнаймося, коли ж у нас більше шансів зловити світло, щоб зарядити EcoFlow. Для цього зробимо pivot-table де по вертикалі візьмемо години, а по горизонталі — дні тижнів, і намалюємо.
dfh = json_df.groupby(pd.Grouper(level='date', freq='H')).mean() dfh['wd'] = dfh.index.day_name() dfh['hour'] = dfh.index.hour heatmap = pd.pivot_table(dfh, values='state', index=['hour'], columns=['wd'], aggfunc=np.mean) column_order = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] heatmap = heatmap.reindex(column_order, axis=1) import matplotlib as mpl fig, ax = plt.subplots(figsize=(7,7)) cmap = plt.colormaps['summer_r'] cmap0 = mpl.colors.LinearSegmentedColormap.from_list( 'red2green', ['red', 'green']) pcm = ax.pcolormesh(heatmap , cmap=cmap0, edgecolors='k', linewidths=0.2,linestyle='dashed') ax.set_frame_on(False) ax.set_yticks(np.arange(heatmap.shape[0]) + 0.5, minor=False) ax.set_xticks(np.arange(heatmap.shape[1]) + 0.5, minor=False) ax.invert_yaxis() ax.xaxis.tick_top() fig.tight_layout() fig.colorbar(pcm, ax=ax) ax.set_xticklabels(column_order, minor=False) ax.set_yticklabels(heatmap.index, minor=False) plt.xticks(rotation=90) ax.grid(False) plt.show()

Тут висновків можна зробити декілька:
- слоти відключень таки можна прослідкувати — діагональні червоні лінії;
- на вихідних світла більше, аніж в будні;
- середа-пʼятниця з 12 до 19 — часто світла нема, навіть поза графіком.
Думаю, цього достатньо.
Висновок
Можемо підсумувати, що графік — це добре, але покластись на нього, поки що, доволі складно. Ну, і віримо в наших енергетиків-титанів і ЗСУ — скоро стане краще. Та і в цілому — ввести повний блекаут в таких тенденціях і поточних запасах ракет русні буде доволі складно. Ну, а щоб пришвидшити нашу перемогу — завжди можна закинути пару донатів на KOLO чи долучитись до зборів DOU.
Якщо у вас є ідеї, чого тут не вистачило, або ви знайшли помилку — велкам в коментарі.
24 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів