19 нояб. 2010 г.

Ленивая дата

Календарные функции, представленные в предыдущей заметке, работают, исходя из наивного предположения, что на вход поступают правильные данные. В каких-то случаях ничего плохого не произойдет. К примеру, при вычислении юлианской даты на 29 февраля 1975г (в феврале 1975 было 28 дней), результат будет таким же как для 1 марта:
$ perl julian.pl 1975 2 29
JD: 2442472.500000
JD at 0h: 2442471.500000
Fri

$ perl julian.pl 1975 3 1
JD: 2442472.500000
JD at 0h: 2442471.500000
Fri
Хорошим такое API никак не назовешь. Самое время задуматься над созданием удобного для использования класса.

В одних случаях нужно получать из календарной даты юлианскую, в других -- наоборот. Поэтому объект должен инициализироваться как из первой, так из второй даты. Если на входе год, месяц и число, то они сохраняются, а юлианская дата неопределена (undef) и вычисляется лениво, т.е только при вызове метода djd. Точно так же, если объект создан с аргументом djd, календарная дата неопределена до тех пор, пока ее не запросили.

В Perl-е не существует перегрузки методов, следовательно, нельзя создать два разных конструктора: один -- с календарной датой на входе, другой с юлианской. Можно было бы передавать в конструктор new именованные аргументы, что-то вроде:
Julian->new(date => \%ymd, djd => $scalar)
Но тогда пришлось бы нагромождать проверки: не заданы ли сразу оба аргумента и какой именно задан. А потом еще проверять правильность либо первого либо второго. Проще создать два "фабричных" метода:
  • new_from_date(year => $scalar, month => $scalar, day => $scalar)
  • new_from_djd($djd)

Как правило, я стараюсь не использовать модуль Params::Validate, который создан как раз для проверки входных параметров -- уж больно велики накладные расходы, да и ни к чему превращать динамический язык в подобие Java. Но тут как раз тот случай, когда без него не обойтись.

Начнем с более простого метода new_from_djd.
...
use Params::Validate qw/:all/;
use Readonly;
Readonly our $DJD_TO_JD => 2415020;

...

sub new_from_djd {
my $class = shift;

validate_pos(
@_,
{
type => SCALAR,
regex => qr/^[-+]?0*(\d+|(?:\d*\।\d+))$/,
callbacks => {
'djd range' => sub{ $_[0] >= -$DJD_TO_JD }
}
}
);

bless {
_djd => shift,
_date => undef,
}, $class
}
...
Поскольку параметры здесь не именованные, а позиционные, для валидации данных используется метод validate_pos из пакета Params::Validate. Имя класса предварительно убрано из списка аргументов ( @_ ). Проверка производится по трем критериям:
  • type => SCALAR
    аргумент должен быть скаляром
  • qr/^[-+]?0*(\d+|(?:\d*\.\d+))$/
    аргумент должен быть целым или десятичным числом, с любым количеством ведущих нулей и с необязательным ведущим "плюсом" или "минусом"
  • 'djd range' => sub{ $_[0] >= -$DJD_TO_JD }
    число не должно быть меньше нулевой стандартной юлианской даты (напомню, что видоизмененная юлианская дата, которую мы используем, больше стандартной на 2415020 суток. Функции обратного вызова (callbacks) -- "тяжелая артиллерия" пакет Params::Validate. Внутри них можно осуществлять любые проверки. На входе всегда два параметра: проверяемый аргумент и ссылка на все аргументы (хэш или массив).

В конце мы "благословляем" (bless) новорожденный экземпляр с инициализированным атрибутом djd и пустой (до поры до времени) календарной датой.


Второй метод-фабрика сложнее:

...
use Params::Validate qw/:all/;
use Readonly;

Readonly our $DJD_TO_JD => 2415020;
Readonly our $MIN_YEAR => -4713;
Readonly our $MAX_YEAR => 4713;

...

sub new_from_date {
my $class = shift;

my %args = validate(
@_,
{
year => {
type => SCALAR,
regex => qr/^[\-+]?\d{1,4}$/,
callbacks => {
'year range' => sub{
$MIN_YEAR <= $_[0] && $MAX_YEAR >= $_[0]
},
'non-zero year' => sub{ 0 != $_[0] }
}
},

month => {
type => SCALAR,
regex => qr/^0*([1-9]|(1[0-2]))$/,
},

day => {
type => SCALAR,
regex => qr/^0*([1-9]|([0-2]\d)|(3[0,1]))(\।\d+)?$/,
callbacks => {
'day range' => sub{
my ($d, $arg) = @_;
$d >= 1 &&
$d < _days_per_month($arg->{year}, $arg->{month}) + 1;
}
}
},
}
);


bless {
_djd => undef,
_date => \%args,
}, $class
}
...
Здесь аргументы именованные, поэтому для их валидации используется метод validate.
  • год проверяется по четырем критериям:
    1. тип аргумента -- скаляр
    2. аргумент -- положительное или отрицательное целое число
    3. диапазон: от 4713г. до н.э до 4713г. н.э.
    4. ноль не допускается

  • У месяца тип должен должен быть скаляром, а диапазон (1-12) проверяется регулярным выражением
    qr/^0*([1-9]|(1[0-2]))$/
  • Проверка числа самая сложная.
    1. тип аргумента -- скаляр.
    2. при помощи регулярного выражения проверяем, что это число, возможно, с десятичными долями; при этом целая часть не выходит за рамки диапазона 1-31.
    3. При окончательной проверке диапазона вызывается внешняя функция _days_per_month (см. ниже)
В конце мы "благословляем" (bless) новорожденный экземпляр с сохраненными в хэше годом, номером месяца и числом.

Сколько дней в месяце?

Количество дней во всех месяцах, кроме февраля, в григорианском календаре неизменно.
Чтобы узнать число дней в феврале, надо определить, является ли год високосным. Если да -- то 29, если нет -- то 28. Для определения "високосности" функция _leap_year. Год в григориансмком календаре является високосным, если он кратен 4 и при этом не кратен 100, либо кратен 400 (в отличие от "старого стиля", где високосным считался каждый четвертый год).
# является ли год високосным?
sub _leap_year {
my $y = shift;
return 1 if $y % 400 == 0;
return 0 if $y % 100 == 0;
return 1 if $y % 4 == 0;
return 0;
}

# количество дней в месяце
sub _days_per_month {
my ($y, $m) = @_;
return _leap_year($y) ? 29 : 28 if $m == 2; # февраль
return 30 if grep{ $m == $_ } (4, 6, 9, 11); # апрель, июнь, сентябрь, ноябрь
return 31; # остальные месяцы
}

Ленивые вычисления

Два getter-а: djd и date служат для получения юлианской и календарной даты. Если либо одно либо другое не определено (undef), оно вычисляется и сохраняется как аттрибут объекта.
sub djd {
my $self = shift;
$self->{_djd} = _date2djd(%{$self->{_date}})
unless defined $self->{_djd};
$self->{_djd}
}

sub date {
my $self = shift;
$self->{_date} = _djd2date($self->{_djd})
unless defined $self->{_date};
$self->{_date}
}
Функции _date2djd и _djd2date, которые заняты вычислениями, уже описаны в предыдущей заметке. Здесь я только добавил к их названиям нижнее подчеркивания в качестве рекомендации не использовать их снаружи.

10 нояб. 2010 г.

Юлианские даты

Гражданский календарь с его високосными годами и реформами крайне неудобен для расчетов. Сколько дней и часов осталось до начала мото-сезона? В какой день недели произошло памятное событие?... Вместо того, чтобы подсчитывать дни в годах и часы в сутках, удобнее пользоваться действительными числами. Я говорю, конечно, не о вычислениях в уме, а о программировании.

Непрерывный счет дней

Одним из широко известных решений этой проблемы стало так называемое "время Unix", измеряемое количеством секунд, прошедших от полуночи 1 января 1970 года. Прикладные языки программирования предоставляют собственные библиотечные средства. Помнится, в Delphi был тип TDateTime, который хранил даты и время в виде десятичных чисел; целая часть представляла количество дней, прошедших с полуночи 30 декабря 1899 года, а дробная часть -- время суток (стандарт OLE Automation). Базы данных, где проблема расчета времени весьма актуальна, делают это по-своему. Например, TIMESTAMP в MySQL 5.1 отсчитывается от 0ч 1 января 1000 года. Python умеет работать с датами в диапазоне от 1 до 9999 г.г. н.э. В языке Java... впрочем, довольно подробностей. Если нас интересуют события космического масштаба, как-то: восходы и заходы, равноденствия и солнцестояния, фазы Луны, координаты планет, наконец, интервалы времени, не ограничивающиеся нашей эрой -- ни одно из этих средств не подойдет. Что понадобится -- так это юлианские даты, система непрерывного счета времени, которую используют астрономы.

За точку отсчета астрономы приняли средний гринвичский полдень 1 января 4713 г. до н.э. Очередная юлианская дата начинается в 12ч00м UT, что на полсуток расходится с началом гражданских суток. Целая часть числа представляет собой число суток, дробная -- доли суток, прошедшие от полудня. Например, 6ч 17 февраля 1985 года соответствует юлианской дате 2446113.75.

Проблемы с точностью

В эпоху первых персональных компьютеров и программируемых калькуляторов возникали проблемы с точностью. В некоторых системах на хранение вещественных чисел отводилось всего 4 байта (то же самое, что сегодняшнее float), что ограничивало точность 6-7 цифрами. Между тем, юлианские даты часто представлены 11-12 цифрами. Скажем, 11ч. 17 августа 1938 года = JD 2429127.9583

Смещение точки отсчета

Можно принять за точку отсчета вместо даты, отстоящей от нас на 6 тысячелетий, момент поближе. Питер Даффетт-Смит в книге "Astronomy With Your Personal Computer" (Cambridge University Press, 1986) предлагает использовать 12ч GMT 31 декабря 1899 года, т.е. ближайший полдень до начала XXв. Вычисления в рамках ближайших столетий становятся точнее, поскольку на хранение переменных потребуется меньше памяти. Даты до этого момента отрицательные. Чтобы перейти от такой даты (назовем ее вслед за Даффетт-Смитом DJD) к стандартной юлианской (JD), надо прибавить к первой 2415020.

Современное восьмибайтное представление вещественных чисел (double), дает точность до 16 цифр, так что проблема, о которой идет речь, больше неактуальна, однако я намерен следовать методу из книги Даффетт-Смита. Тем более, что момент 12ч UT 31 декабря 1989 года взят не с потолка. Забегая вперед, скажу, что во множестве астрономических расчетов используется время в юлианских столетиях, прошедшее именно от этой даты (T). Формула такова:
T = (JD - 2415020) / 36525
Здесь JD -- юлианская дата. То же самое, что DJD / 36525. Делитель, как нетрудно догадаться, представляет собой количество суток в условном юлианском столетии.

Между прочим, класс DateTime из одноименного пакета Perl содержит метод jd, выдающий стандартную юлианскую дату. В базе SQLite имеется функция для ее получения.

От календаря к юлианской дате

Алгоритм пересчета календарной даты в юлианскую суров и непригляден. Его реализации на любых языках программирования
выглядят примерно одинаково. Ниже представлен ее вариант на языке Perl.

sub date2djd {
my %args = @_;
my $d = $args{date};
my $m = $args{month};
my $y = $args{year};

$y++ if $y < 0;
if ($m < 3) {
$m += 12;
$y--
}
my $b;
if (after_gregorian($y, $m, $d)) {
my $a = floor($y / 100);
$b = 2 - $a + floor($a / 4);
}
else {
$b = 0
}
my $f = 365.25 * $y;
my $c = floor( $y < 0 ? $f - 0.75 : $f ) - 694025;
my $e = floor(30.6001 * ($m + 1));
return $b + $c + $e + $d - 0.5
}
На входе у нее именованные аргументы:
  • year -- номер года, отрицательный для годов до н.э. Нулевое значение недопустимо, т.е. за -1 следует +1 вместо 0.
  • month - номер месяца (1-12)
  • day -- день месяца с долями суток. Например, 07ч 40м первого числа будет соответствовать 1 + (7 + 40 / 60.0) / 24.0
Правильность передаваемых аргументов не проверяется. Все проверки будут реализованы позже, при создании ООП-обертки.

Календарная дата относится к пролептическому григорианскому календарю. Так называется григорианский
календарь, который используется для обозначения дат, предшествовавших его введению. А применен он был впервые 15 октября 1582 года. Вспомогательная функция after_gregorian как раз и помогает определить, приходится ли заданная дата на период до или после календарной реформы

sub after_gregorian {
my ($y, $m, $d) = @_;
return 0 if $y < 1582;
return 1 if $y > 1582;
return 0 if $m < 10;
return 1 if $m > 10;
return 0 if $d <= 15;
return 1;
}
Использовать эту функцию самостоятельно, вне пролептического календаря, не имеет смысла, потому что григорианское летоисчисление не было принято везде одновременно. Наша страна перешла на "новый стиль" в 1918 году.

От юлианской даты к календарю

А вот как выглядит обратная функция, переводящая юлианскую дату в календарную.

sub djd2date {
my $djd = shift;

my $d = $djd + 0.5;
my ($f, $i) = modf($d);

if ($i > -115860) {
my $a = floor($i / 36524.25 + 9.9835726e-1) + 14;
$i += 1 + $a - floor($a / 4);
}
my $b = floor($i / 365.25 + 8.02601e-1);
my $c = $i - floor(365.25 * $b + 7.50001e-1) + 416;
my $g = floor($c / 30.6001);
my $dy = $c - floor(30.6001 * $g) + $f;
my $mn = $g - ($g > 13.5 ? 13 : 1);
my $yr = $b + ($mn < 2.5 ? 1900 : 1899 );
$yr-- if $yr < 1;

return {year => $yr, month => $mn, day => $dy}
}
На входе DJD -- число юлианских дней от начала XX века. На выходе -- хэш того же вида, что и на входе date2jd.

День недели

Система юлианских дней позволяет довольно просто вычислить день недели. Для этого достаточно:
  1. Взять юлианскую дату на начало календарных суток (0ч UT).
  2. Прибавить к ней 1.5.
  3. Найти остаток от деления результата на 7
0 будет соответствовать воскресенью, 1 -- понедельнику, 2 -- вторнику и так до 6 -- субботы.

Добавим две новые функции:
  • jd_midnight для вычисления юлианской даты на календарную полночь, исходя из заданной юлианской даты
  • weekday для вычисления дня недели по юлианской дате на начало суток.

#!/usr/bin/perl -w
use strict;
use warnings;
use POSIX qw/modf floor/;
use Readonly;

Readonly our $DJD_TO_JD => 2415020;
Readonly our @WEEKDAYS => qw/Sun Mon Tue Wed Thi Fri Sat/;

....

sub jd_midnight {
my $j = shift;
my $f = floor($j);
return $f + (abs($j - $f) > 0.5 ? 0.5 : -0.5);
}

sub weekday {
my $j = shift;
my $j0 = jd_midnight($j);
return ($j0 + 1.5) % 7
}


if (@ARGV < 3) {
print "Usage: perl julian.pl YEAR MONTH DAY";
exit(0)
}

my $djd = date2djd(year => $ARGV[0], month => $ARGV[1], day => $ARGV[2]);
my $j = $djd + $DJD_TO_JD;
printf "JD: %f\n", $j;
my $j0 = jd_midnight($j);
printf "JD at 0h: %f\n", $j0;
my $w = weekday($j);
print $WEEKDAYS[$w], "\n";
На месте многоточий в скрипте должны фигурировать функции date2djd и after_gregorian.
Важно: на входе jd_midnight и weekday стандартная юлианская дата, а не DJD. Иначе пришлось бы возиться с отрицательными значениями.

Пример: в какой день недели был запущен первый советский спутник? Известно, что это произошло 4 октября 1957 года 19ч28м34с UT.
Третьим аргументом (день) будет:((34 / 60 + 28) / 60 + 19) / 24 + 4 = 4.8115
$ perl julian.pl 1957 10 4.8115
JD: 2436116.311500
JD at 0h: 2436115.500000
Fri
Пятница, все правильно.

Применение юлианской даты для вычисления дня недели -- стрельба из пушки по воробьям. Но это только начало. Дальше речь пойдет о решении куда более интересных календарных и астрономических задач, а уж там без юлианской даты никуда.

9 нояб. 2010 г.

Perl и рекурсия: ура!

В прошлой заметке выяснилось, что в Python-е рекурсия работает медленнее, чем for-цикл, а тот, в свою очередь -- медленнее, чем while. С циклами все понятно, while всегда быстрее. Что же касается рекурсии, то мне стало любопытно: специфична ли ее низкая производительность для Python-а или это везде так. И я решил ту же задачку -- получение из десятичного числа градусов, минут и секунд -- на Perl-е. Правда, ограничившись двумя
функциями: рекурсивной и циклом-while. Было интересно, насколько сильно здесь рекурсия отстает от цикла.

Результат удивил: рекурсия оказалась эффективнее.



Benchmark: timing 1000000 iterations of Recursive, While-loop...
Recursive: 136.521 wallclock secs (119.61 usr + 0.64 sys = 120.25 CPU) @ 8316.01/s (n=1000000)
While-loop: 142.86 wallclock secs (123.91 usr + 0.66 sys = 124.57 CPU) @ 8027.61/s (n=1000000)



Получается, что на миллион вызовов рекурсивной функции понадобилось 136 секунд. На столько же вызовов нерекурсивной функции ушло 142 секунды. За секунду рекурсивная функция успела отработать примерно на 300 раз больше.
Разница невелика -- и этот факт радует, потому что как система Perl ведет себя предсказуемо. Я не хочу вникать в устройство интерпретатора каждый раз когда приходится задумываться о реализации какого-то алгоритма.

Вот скрипт целиком:


8 нояб. 2010 г.

Python и рекурсия? Увы...

Задача пересчета градусов, выраженных десятичной дробью, в градусы, минуты и секунды для положительного числа x решается тривиально:
  1. Полагаем, что x = исходное число.
  2. Находим целую (i) и дробную (f) часть x.
  3. Целую часть сохраняем как градусы, минуты или секунды.
  4. Если найдены три числа, конец.
  5. Полагаем x = f * 60 и возвращаемся к шагу 2.
Понятно, что эти вычисления производятся в цикле, однако подробности организации цикла пока не важны.
Как быть с отрицательными числами? В этом случае алгоритм немного усложняется:
  1. Полагаем, что x = исходное число.
  2. Находим целую (i) и дробную (f) часть от абсолютного значения x.
  3. Если x положительное, переходим к шагу 5.
  4. Если i не равно 0, полагаем i = -i. В противном случае, f = -f
  5. Целую часть сохраняем как градусы, минуты или секунды.
  6. Если найдены три числа, конец.
  7. Полагаем x = f * 60 и возвращаемся к шагу 2.
Теперь при отрицательном исходном числе, первое значащее число будет отрицательным. Так,
  •  11.75 -- 11:45:0
  • -11.75 -- -11:45:0
  •  -0.75 -- 0:-45:0
Кроме того, удобнее будет секунды возвращать как вещественное, а не целое число, потому что в противном случае при обратном пересчете (градусов, минут и секунд в десятичные градусы) не будет получаться исходное число.
  1. Полагаем, что x = исходное число.
  2. Если при текущем повторении вычисляются секунды, сохраняем x как секунды и выходим из цикла.
  3. Находим целую (i) и дробную (f) часть от абсолютного значения x.
  4. Если x положительное, переходим к шагу 6
  5. Если i не равно 0, полагаем i = -i. В противном случае, f = -f
  6. Целую часть сохраняем как градусы или минуты.
  7. Полагаем x = f * 60 и возвращаемся к шагу 2.
Когда в твоем распоряжении язык высокого уровня можно потратить немало времени в поисках наиболее красивой, эффективной и органичной для языка реализации этого алгоритма. В Python-е имеются как минимум три способа:
  1. рекурсивно;
  2. при помощи циклов: for и while;
  3. с использованием генераторов.
Начнем с рекурсии.



def dms1(x, num_vals=3):

    def mul_60(x, count=1):
        if count == num_vals:
            return [x]
        f, i = math.modf(abs(x))
        if x < 0:
            if i == 0:
                f = -f
            else:
                i = -i
        res = mul_60(f*60.0, count+1)
        res.append(int(i))
        return res

    return tuple(reversed(mul_60(x)))


Функция возвращает кортеж из шестидесятиричных значений: (градусы, минуты, секунды). Именованный параметр num_vals указывает на то, сколько значений необходимо вернуть. Если секунды не нужны, num_vals=2.

Рассмотрим альтернативные способы. Чтобы не повторять один и тот же код, вынесем общую часть в отдельную функцию:

def _next_fi(f):
    negative = f < 0
    f, i = modf(abs(f))
    if negative:
        if i == 0:
            f = -f
        else:
            i = -i
    return (f * 60.0, int(i))

Функция, делающая то же самое при помощи for-цикла, выглядит менее изящно:

def dms2(x, num_vals=3):
    arr = []
    last_j = num_vals - 1
    f = x
    for j in range(0, num_vals):
        if j == last_j:
            arr.append(f)
        else:
            f, i = _next_fi(f)
            arr.append(i)
    return tuple(arr)


А вот вариант с циклом while:

def dms3(x, num_vals=3):
    arr = []
    j = 1
    f = x
    while(j <= num_vals):
        if j == num_vals:
            arr.append(f)
        else:
            f, i = _next_fi(f)
            arr.append(i)
        j += 1
    return tuple(arr)

На очереди -- генераторы. Первый из них использует for, второй -- while

def dms4(x, num_vals=3):
    def mul_60(f):
        last_j = num_vals - 1
        for j in range(0, num_vals):
            if j == last_j:
                yield f
            else:
                f, i = _next_fi(f)
                yield i
    
    return tuple([v for v in mul_60(x)])


def dms5(x, num_vals=3):

    def mul_60(f):

        j = 1
        while(j <= num_vals):
            if j == num_vals:
                yield f
            else:
                f, i = _next_fi(f)
                yield i
            j += 1
    
    return tuple([v for v in mul_60(x)])


Какая из функций работает быстрее всех? Для измерения я воспользовался стандартным методом Timer.timeit, которая по умолчанию запускает заданный код миллион раз. Получилось вот что:

Benchmarking X -> D,M,S

06.465 сек.: 'while'-цикл
07.600 сек.: 'while'-генератор
07.768 сек.: 'for'-цикл
08.544 сек.: 'for'-генератор
09.995 сек.: рекурсия

Увы, самый изящный, на мой взгляд, способ оказался самым медленным! А выиграла самая примитивная функция -- даже не генераторы.

19 февр. 2010 г.

Входной билет на собеседование

Письмо, полученное сегодня утром, показалось мне знакомым. Так и есть! В 2008 году та же "ведущая игровая компания" уже искала Perl программистов. Требования к кандидатам и условия работы стандартные. Далее шла неуклюжая формулировка:

"Решением о дальнейшем сотрудничестве являются:
Успешно выполненное тестовое задание;
Положительные результаты собеседования."

Тестовое задание прилагалось -- даже не одно, а два.
  1. В первом предлагалось написать сценарий регистрации посетителя веб-сайта, входа в систему и редактирования профиля. Дополнительные требования звучали несколько экзотично. Скажем, нельзя использовать модуль CGI (стандартную библиотеку, входящую в дистрибутивы Perl-а, которую обойдет мимо разве что любитель изобретать велосипеды).
  2. Второе задание состояло из 6 вопросов по SQL. Для каждого рекомендовалось привести несколько решений.
Допустим, веб-приложение я напишу за вечер. Хотя нет, вечера не хватит, учитывая необходимость дублировать CGI. Хоть в условиях и сказано, что можно ограничиться GET-запросами (а POST, мол, является дополнительным плюсом), у меня рука не поднимется передавать логин и пароль пользователя так, чтобы они были видны в адресной строке браузера. Так что, если в пятницу вечером я сяду, то закончу в субботу часикам к четырем. На второе задание уйдет как минимум полдня.

И в том и другом случае особого ума не требуется, это рутина. Но рутина трудоёмкая. А на выходные у меня уже имеются кое-какие планы. Во-первых, мы с Мариной собирались на концерт, в кои-то веки. Во-вторых, детям при моей занятости недостает внимания. Позавчера сижу в офисе, Марина звонит, жалуется: "...Два сапога -- пара, только что поругались и принялись кидаться друг в друга едой". В-третьих, есть пара собственных проектов, на которые катастрофически не хватает времени. Так что, увольте. Выходные -- слишком высокая цена для входного билета на собеседование.

Два случая, когда человек не откажется тратить время на такие задания:
  1. Он мечтает попасть в эту компанию. Мне самому ни разу не приходилось слышать о компании "Адептус". Но, наверное, я не в теме. Ясно, что существует она не меньше двух лет, причем -- кризисных, хороший признак. Может, программисты валят туда толпами и они выбирают лишь тех, кто готов на все лишь бы получить там место.
  2. Нет выбора. С работы уволили, деньги кончились, больше никуда не берут.
Больше ничего не приходит в голову.

PS. Кто-то метко сказал: "тестовое задание я готов выполнить за тестовую зарплату".