В футболе есть два популярных турнира: Лига Чемпионов и Лига Европы. На основании их результатов рассчитывается так называемый Рейтинг футбольных ассоциаций. На базе этого рейтинга в дальнейшем определяется, какое количество команд от каждой страны будет участвовать в последующих турнирах.
В этой статье я создам приложение на основе открытой и бесплатной платформы lsFusion, которое будет рассчитывать этот рейтинг. Оно будет хранить все свои данные в PostgreSQL, предоставлять веб интерфейс по их изменению и отображению с возможностями фильтрации и сортировки, а также импортировать результаты матчей при помощи специального API.
Весь код для реализации этого приложения будет состоять из около 300 значащих строк.
Доменная логика
Создание любой информационной системы начинается с задания доменной логики.
В первую очередь, логично выделить самые простые справочники, у которых есть только код и наименование:
- Турнир. Лига Чемпионов или Лига Европы.
- Сезон. 2018-2019 / 2017-2018 и т.д.
- Раунд. Финал, Полуфинал, Групповая стадия и т.д. Его можно считать как композицию к турниру, но в данной реализации я выделил его как отдельную сущность.
- Страна. В данном приложении используется как Футбольная ассоциация. Например, клуб Монако находится в стране Монако, но играет во французском чемпионате.
- Клуб. Барселона, Реал, Манчестер Юнайтед и т.д.
Так как для их объявления в lsFusion используется однотипная логика, то объявим метакод (или шаблон кода), который будет генерировать соответствующую логику:
META defineMasterObject(object, caption, captions, nameLength) |
Он будет объявлять:
- Класс с заданным именем
- Свойства с кодом и наименованием для нового класса
- Три формы: редактирование объекта, форму со списком всех объектов, которая затем добавляется в навигатор, диалог по выбору этого объекта. В качестве диалога можно использовать вторую форму, но тогда у пользователя будет возможность изменять объекты при выборе, что может приводить к ошибкам со стороны пользователей.
В метакод передаются четыре параметра:
- Идентификатор (object). С таким именем будут создаваться классы и формы. Конструкция ### используется, чтобы в результирующий код первая буква идентификатора делалась заглавной.
- Название в единственном числе. Используется для заголовка класса и формы.
- Название во множественном числе. Используется для формы со списком и диалога.
- Длина наименования. В наименованиях разных объектов ожидаются разные длины, что важно при построении интерфейса.
Используя созданный метакод добавим пять вышеописанных сущностей:
@defineMasterObject(tournament, 'Турнир', 'Турниры', 20); |
Сгенерированный код, например, для турнира будет выглядеть следующим образом:
CLASS Tournament 'Турнир';
|
К сгенерированной логике клубов добавим ссылку на страну. Для этого сначала создадим соответствующее свойство, которое затем вынесем на формы редактирования и просмотра клуба:
country = DATA Country (Team); |
Всю созданную логику положим в отдельный модуль Master (файл Master.lsf).
Теперь создадим сущность Лига. Она будет определять турнир определенного сезона. Например, Лига Чемпионов 2017-18 или Лига Европы 2018-19. У лиги не будет наименования, а только ссылки на турнир и сезон. Поэтому предыдущим метакодом пользоваться не будем, а сделаем аналогичную логику и поместим в новый модуль League:
MODULE League;
|
И, наконец, добавим логику матчей. Для этого создадим класс Матч, который будет ссылаться на лигу и раунд. Для него также будут заданы клубы, которые в нем участвовали, и результат. Все это поместим в отдельный модуль Match:
MODULE Match;
|
Импорт данных
К сожалению, мне удалось найти только один общедоступный и бесплатный API, поддерживающее все еврокубки. Это API Football. Однако, там есть свои проблемы:
- Отсутствуют результаты до 2016 года.
- Отсутствуют результаты квалификации Лиги Европы до 2018 года.
- Есть определенные ошибки в данных. Например, Irtysh Pavlodar отнесен к России, хотя этот клуб представляет Казахстан. Также Europa Fc почему-то относится к Испании вместо Гибралтара.
Ошибки в данных можно исправить вручную при помощи созданных ранее форм. Однако, так как расчет общего коэффициента идет на основе последних пяти лет, то посчитать общий коэффициент из данных API Football, к сожалению, не получится. Если кто-то в комментариях предложит, откуда получить нужные данные в любом формате за предыдущие годы, то буду очень признателен. Но, так как есть полные данные за 2018 год, то можно будет проверить корректность расчета хотя бы за этот год.
Нужный нам API реализован в виде HTTP запросов, где параметры передаются через url, а в заголовке указывается специальный ключ доступа. Объявим соответствующую логику:
host = 'api-football-v1.p.rapidapi.com'; |
Все действия по импорту данных поместим на ранее созданную форму leagues. Туда же поместим ключ доступа в тулбар таблицы со списком лиг:
EXTEND FORM leagues |
Для начала реализуем получение списка лиг. Для этого в API Football есть специальный url: /leagues. GET запрос к нему возвращает JSON вида:
{
"api":{
"results":2,
"leagues":[
{
"league_id":1,
"name":"2018 Russia World Cup",
"country":"World",
"country_code":null,
"season":2018,
"season_start":"2018-06-14",
"season_end":"2018-07-15",
"logo":"https://www.api-football.com/public/leagues/1.png",
"flag":null,
"standings":0,
"is_current":1
},
{
"league_id":2,
"name":"Premier League",
"country":"England",
"country_code":"GB",
"season":2018,
"season_start":"2018-08-10",
"season_end":"2019-05-12",
"logo":"https://www.api-football.com/public/leagues/2.png",
"flag":"https://www.api-football.com/public/flags/gb.svg",
"standings":1,
"is_current":1
}
]
}
}
Для формирования GET запроса к нему и записи body ответа используется следующая конструкция:
LOCAL result = FILE(); |
Она записывает результат в локальное свойство result без параметров типа FILE.
Для разбора файла в формате JSON строится форма, структура которой соответствует структуре JSON. Сгенерировать ее можно в IDE при помощи пункта меню:
Для вышеприведенного JSON’а форма будет выглядеть следующим образом (с учетом только тех значений, которые будут импортироваться):
GROUP api;
|
Для непосредственно импорта из свойства result JSON’а в формате формы importLeagues используется следующая команда:
IMPORT importLeagues JSON FROM result(); |
После ее выполнения в свойства tournamentName, seasonName и leagueId будут помещены соответствующие значения из JSON файла:
То есть значение для tournamentName(0) будет “World Cup”, а в tournamentName(1) — “Premier League”.
К сожалению, в API Football вообще нет сущности турнир. Единственным способом связать все лиги является наименование, которое совпадает для лиг одного турнира из разных сезонов. Для этого в импорте сначала группируем все наименования импортированных лиг и, в случае отсутствия в базе, создаем новые турниры:
FOR [GROUP SUM 1 BY tournamentName(INTEGER i)](STRING tn) AND NOT tournament(tn) DO NEW t = Tournament { |
Для сезонов также нет кодов, поэтому при импорте лиг они создаются аналогично. После того, как отсутствующие объекты созданы, импортируются непосредственно лиги. Поиск турниров и сезонов идет по наименованию при помощи построенных ранее через GROUP AGGR свойств:
FOR leagueId(INTEGER i) AND NOT league(leagueId(i)) DO NEW l = League { |
По умолчанию, данные загрузятся, но в базу будут сохранены только когда пользователь нажмет кнопку Сохранить на форме. При необходимости можно в конце действия добавить команду APPLY, чтобы она сразу сохранилась в базу без предпросмотра.
И, наконец, добавляем действие по импорту на форму со списком лиг:
EXTEND FORM leagues |
Аналогичным образом реализуем импорт клубов и матчей. Однако, так как API предоставляет возможность импортировать их только для конкретной лиги, то действие должно принимать на вход лигу:
// Импорт клубов |
Для матчей есть своя особенность: коды команд идут внутри дополнительных тегов homeTeam и awayTeam. Для них создаются соответствующие группы по аналогии с api. При этом внутри они имеют одинаковые тэги team_id. Так как на форму нельзя добавлять свойства с одинаковым именем, используется специальное ключевое слово EXTID, которое определяет имя тэг в импортируемом JSON.
Для того, чтобы все импорты были на одной форме, и так как они привязаны к лигам, то выносим их всех на одну форму. Кроме того, добавляем на форму команды и матчи, чтобы иметь возможность видеть перед сохранением то, что импортируется:
EXTEND FORM leagues |
Результирующая форма будет выглядеть следующим образом:
Весь импорт положим в отдельный модуль APIFootball.
Расчет коэффициента
Перейдем непосредственно к расчету странового коэффициента УЕФА. Весь код логично положить в специально заведенный для этого модуль UEFA.
Для начала учтем, что API Football предоставляет интерфейс по импорту всех матчей, а не только еврокубков. Поэтому отделим именно еврокубковые матчи по наименованию турнира (правильнее завести отдельное первичное свойство для этого, но реализацию свойств всегда можно будет изменить без модификации всей остальной логики):
isCL (Tournament t) = name(t) = 'Champions League'; |
Для начала рассчитаем очки, которая получает каждый клуб в сезоне за результаты конкретных матчей.
В течение этого периода каждая команда получает:
2 очка в случае победы;
1 очко в случае ничьей.
Начиная с 1999 года, эти очки делятся на два в случае, если они заработаны в квалификационных раундах, то есть:
1 очко в случае победы;
0,5 очка за ничейный результат.
Создадим вспомогательные свойства, которые определяют отношение матча и клуба:
played (Team t, Match m) = homeTeam(m) = t OR awayTeam(m) = t; |
Для определения того, сколько в каждом матче набирается очков, добавим первичное свойство числового типа для раунда, которое по умолчанию будет равно единице:
dataMatchCoeff = DATA NUMERIC[10,1] (Round); |
Дальше считаем очки за победы и ничьи и складываем вместе:
wonPoints 'Очки за победы' (Season s, Team t) = |
Очки за матчи помечаем как MATERIALIZED, чтобы они сохранялись в таблицу, а не рассчитывались каждый раз.
Теперь нужно еще посчитать бонусные очки:
Кроме этого, начисляются бонусные очки:
По 1 очку даётся в случае выхода команды в четвертьфинал, полуфинал и финал в европейских кубках;
4 очка за выход в групповую стадию Лиги чемпионов (до 1996 года — 2 очка, с 1997 по 2003 — 1 очко, c 2004 по 2008 — 3 очка);
5 очков в случае выхода команды в 1/8 финала Лиги чемпионов (до 2008 года — 1 очко).
В расчёт берутся только сыгранные матчи (технические поражения не учитываются). Матчи, завершившиеся серией послематчевых пенальти, при подсчёте коэффициента считаются в соответствии с тем результатом, который зафиксирован по результатам игры в основное и дополнительное время.
В этой реализации будем считать, что клуб прошел в раунд турнира, если он сыграл в нем хотя бы один матч. Для этого посчитаем, сколько матчей сыграл клуб в конкретном сезоне, турнире, раунде:
played 'Играл' (Season s, Tournament t, Round r, Team tm) = |
Теперь нужно определить сколько начислять очков за проход в конкретный раунд. Так как это зависит от турнира (например, за проход в ⅛ Лиги Чемпионов дается 5 очков, а в Лиге Европы — ничего). Для этого введем первичной свойство:
bonusPoints 'Бонус за проход' = DATA NUMERIC[10,1] (Tournament, Round); |
Теперь посчитаем бонусные очки и суммарное количество очков по клубу за сезон:
bonusPoints 'Бонусные очки' (Season s, Team tm) = GROUP SUM bonusPoints(Tournament t, Round r) IF played(s, t, r, tm) MATERIALIZED;
|
Наконец, переходим непосредственно к страновому коэффициенту.
Для расчёта рейтинга ассоциации все очки, набранные клубами, принявшими участие в Лиге чемпионов и Лиге Европы, складываются, и результат делится на количество клубов от этой ассоциации[2][3].
Посчитаем количество клубов по каждой ассоциации, которые приняли участие в еврокубках:
matchesUL 'Матчей в еврокубках' (Season s, Team t) = GROUP SUM 1 IF played(t, Match m) AND season(m) = s AND isUL(m); |
Теперь считаем общее количество очков по ассоциации за сезон и делим на количество клубов:
totalPoints 'Очки (всего)' (Season s, Country c) = GROUP SUM points(s, Team t) IF country(t) = c; |
Рейтинг страны представляет собой сумму коэффициентов страны за предыдущие 5 лет.
Для этого проводим нумерацию всех сезонов начиная с последнего по внутреннему коду (будем считать, что последние добавлялись позже и имеют больший код).:
index 'Индекс' (Season s) = PARTITION SUM 1 IF s IS Season ORDER DESC s; |
При необходимости, можно ввести отдельное поле или нумеровать по наименованию.
Осталось только рассчитать итоговый рейтинг по стране:
rating 'Рейтинг' (Country c) = GROUP SUM points(Season s, c) IF index(s) <= 5; |
Выше мы объявили коэффициенты для турниров и раундов. Добавим их на форму редактирования турнира, при этом фильтруя только те раунды, которые были в этих турнирах:
matches (Tournament t, Round r) = GROUP SUM 1 IF tournament(Match m) = t AND round(m) = r;
|
Настройки коэффициентов, например, для Лиги Чемпионов нужно установить вот так:
Нарисуем форму, которая будет отображать рейтинг, где для каждой страны будут показываться команды, а для каждой команды ее матчи:
FORM countryCoefficientUEFA 'Коэффициент стран UEFA' |
Выглядеть результирующая форма будет вот так:
Цветом в таблицах клубов показывается, когда он принимал участие в сезонах, а в таблице матчей — кто победил.
На картинке видно, что рейтинги за 2018 год подсчитаны точно так же, как в википедии. За предыдущие года, как говорилось выше, API Football предоставляет не всю информацию.
Итог
Мы построили небольшое приложение, которое полностью описывается вышеописанным кодом и хранит свои данные в PostgreSQL, предоставляет веб интерфейс по просмотру и редактированию данных. При этом оно будет эффективно работать на больших объемах, так как все формы считывают только видимое окно. Также из коробки работают фильтры, сортировки, выгрузки в Excel и прочее.
Следует отметить, как легко при помощи платформы задача по расчету коэффициента была декомпозирована на отдельные свойства. При выполнении вся эта логика будет транслирована в SQL запросы, и все расчеты будут произведены непосредственно на сервере базы данных с использованием всех оптимизаций СУБД.
Пример работы приложения с загруженными в него данными можно посмотреть по адресу: https://demo.lsfusion.org/euroleague. Логин guest без пароля. Пользователю включен режим readonly.
Желающие могут установить себе все локально и, например, моделировать коэффициенты путем ввода результатов будущих матчей. Все описанные выше модули приложения размещены на github. После автоматической установки нужно будет просто подкинуть эти файлы в соответствующую папку из инструкции и перезапустить сервер.
Для того, чтобы загрузить данные из API Football, нужно зарегистрироваться у них и получить ключ API. Требует карточку, но если делать не более 50 запросов в день, то списываться с нее ничего не будет.
Кроме того, можно запустить это приложение онлайн в соответствующем разделе на сайте. На вкладке Платформа нужно выбрать пример Расчет коэффициентов УЕФА и нажать Play.
Кстати, если кому-то необходимо реализовать какую-то несложную систему, для которой Excel уже не подходит, то пишите в комментариях. В целях обучения возможностям платформы постараемся ее реализовать и напишем соответствующую статью.
Автор: KabakovichMaxim