feat(legacy): implement subset sum solution to show scheduling (#3019)
### Description When running a radio station it is generally a good idea to reduce dead air time. The current algorithm for adding tracks to a block/show can leave a lot of dead air time at the end as it doesn't use a very good algorithm. Adding tracks to a show until it is full while making it as full as possible is a well known problem in computer science. It is the [Subset Sum Problem](https://en.wikipedia.org/wiki/Subset_sum_problem). This PR implements a Randomized Greedy with Local Improvement (RGLI) approximation solution for the Subset Sum Problem. The new algorithm is only used when sort type is random and overflow is not enabled and there is no limit on the number of tracks that can be used. **This is a new feature**: Improvement on an existing feature. **I have not updated the documentation to reflect these changes**: I did not update the documentation because the current scheduling algorithm is not currently documented and its existing features have not changed. ### Testing Notes **What I did:** I first attempted a fully polynomial time approximation scheme solution, however it is really bad at finding good solutions for high density values and can kinda slow the more tracks/time you have. So I instead implemented an RGLI which is O(nlogn) and has been giving much better results. I implemented the solution in a separate project and tested it and timed the values with a normal distribution of 500 songs with a mean of 3 minutes and a standard deviation of 1 minute. With a show size of 1 hour the algorithm took around 10-15 ms to run. When adjusting the block size and track size the algorithm still was pretty quick to run. Am going to be testing on an instance with lots of tracks later, will update PR when I have done that. **How you can replicate my testing:** _How can the reviewer validate this PR?_ ### **Links** Closes #3018
This commit is contained in:
parent
16deaf08c6
commit
5b5c68c628
|
@ -1300,89 +1300,87 @@ SQL;
|
||||||
$info = $this->getListofFilesMeetCriteria($show);
|
$info = $this->getListofFilesMeetCriteria($show);
|
||||||
$files = $info['files'];
|
$files = $info['files'];
|
||||||
$limit = $info['limit'];
|
$limit = $info['limit'];
|
||||||
$repeat = $info['repeat_tracks'];
|
$repeat = $info['repeat_tracks'] == 1;
|
||||||
$overflow = $info['overflow_tracks'];
|
$overflow = $info['overflow_tracks'] == 1;
|
||||||
|
$isRandomSort = $info['sort_type'] == 'random';
|
||||||
$insertList = [];
|
$blockTime = $limit['time'];
|
||||||
$totalTime = 0;
|
$blockItems = $limit['items'];
|
||||||
$totalItems = 0;
|
|
||||||
|
|
||||||
if ($files->isEmpty()) {
|
if ($files->isEmpty()) {
|
||||||
return $insertList;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// this moves the pointer to the first element in the collection
|
// this moves the pointer to the first element in the collection
|
||||||
$files->getFirst();
|
$files->getFirst();
|
||||||
$iterator = $files->getIterator();
|
$iterator = $files->getIterator();
|
||||||
|
|
||||||
$isBlockFull = false;
|
/**
|
||||||
|
* @var Track[] $tracks
|
||||||
while ($iterator->valid()) {
|
*/
|
||||||
|
$tracks = [];
|
||||||
|
$maxItems = 500;
|
||||||
|
while ($iterator->valid() && count($tracks) < $maxItems) {
|
||||||
$id = $iterator->current()->getDbId();
|
$id = $iterator->current()->getDbId();
|
||||||
$fileLength = $iterator->current()->getCueLength();
|
$fileLength = $iterator->current()->getCueLength();
|
||||||
$length = Application_Common_DateHelper::calculateLengthInSeconds($fileLength);
|
$length = Application_Common_DateHelper::calculateLengthInSeconds($fileLength);
|
||||||
// if the block is setup to allow the overflow of tracks this will add the next track even if it becomes
|
|
||||||
// longer than the time limit
|
|
||||||
if ($overflow == 1) {
|
|
||||||
$insertList[] = ['id' => $id, 'length' => $length];
|
|
||||||
$totalTime += $length;
|
|
||||||
++$totalItems;
|
|
||||||
}
|
|
||||||
// otherwise we need to check to determine if the track will make the playlist exceed the totalTime before
|
|
||||||
// adding it this could loop through a lot of tracks so I used the totalItems limit to prevent
|
|
||||||
// the algorithm from parsing too many items.
|
|
||||||
|
|
||||||
else {
|
$tracks[] = new Track($id, $length);
|
||||||
$projectedTime = $totalTime + $length;
|
|
||||||
if ($projectedTime > $limit['time']) {
|
|
||||||
++$totalItems;
|
|
||||||
} else {
|
|
||||||
$insertList[] = ['id' => $id, 'length' => $length];
|
|
||||||
$totalTime += $length;
|
|
||||||
++$totalItems;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ((!is_null($limit['items']) && $limit['items'] == count($insertList)) || $totalItems > 500 || $totalTime > $limit['time']) {
|
|
||||||
$isBlockFull = true;
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$iterator->next();
|
$iterator->next();
|
||||||
}
|
}
|
||||||
|
|
||||||
$sizeOfInsert = count($insertList);
|
/**
|
||||||
|
* @var Track[] $insertList
|
||||||
|
*/
|
||||||
|
$insertList = [];
|
||||||
|
$totalTime = 0;
|
||||||
|
if ($isRandomSort && !$overflow && $blockItems === null) {
|
||||||
|
$minTrackLength = min(array_map(fn (Track $item) => $item->length, $tracks));
|
||||||
|
do {
|
||||||
|
$solution = SSPSolution::solve($tracks, $blockTime - $totalTime);
|
||||||
|
$insertList = array_merge($insertList, $solution->tracks);
|
||||||
|
$totalTime += $solution->sum;
|
||||||
|
} while ($repeat && ($blockTime - $totalTime) > $minTrackLength);
|
||||||
|
shuffle($insertList);
|
||||||
|
} else {
|
||||||
|
$isFull = function () use (&$blockItems, &$insertList, &$totalTime, &$blockTime) {
|
||||||
|
return $blockItems !== null && count($insertList) >= $blockItems || $totalTime > $blockTime;
|
||||||
|
};
|
||||||
|
|
||||||
// if block is not full and repeat_track is check, fill up more
|
$addTrack = function (Track $track) use ($overflow, $blockTime, &$insertList, &$totalTime) {
|
||||||
// additionally still don't overflow the limit
|
if ($overflow) {
|
||||||
while (!$isBlockFull && $repeat == 1 && $sizeOfInsert > 0) {
|
$insertList[] = $track;
|
||||||
Logging::debug('adding repeated tracks.');
|
$totalTime += $track->length;
|
||||||
Logging::debug('total time = ' . $totalTime);
|
|
||||||
|
|
||||||
$randomEleKey = array_rand(array_slice($insertList, 0, $sizeOfInsert));
|
|
||||||
// this will also allow the overflow of tracks so that time limited smart blocks will schedule until they
|
|
||||||
// are longer than the time limit rather than never scheduling past the time limit
|
|
||||||
if ($overflow == 1) {
|
|
||||||
$insertList[] = $insertList[$randomEleKey];
|
|
||||||
$totalTime += $insertList[$randomEleKey]['length'];
|
|
||||||
++$totalItems;
|
|
||||||
} else {
|
|
||||||
$projectedTime = $totalTime + $insertList[$randomEleKey]['length'];
|
|
||||||
if ($projectedTime > $limit['time']) {
|
|
||||||
++$totalItems;
|
|
||||||
} else {
|
} else {
|
||||||
$insertList[] = $insertList[$randomEleKey];
|
$projectedTime = $totalTime + $track->length;
|
||||||
$totalTime += $insertList[$randomEleKey]['length'];
|
|
||||||
++$totalItems;
|
if ($projectedTime <= $blockTime) {
|
||||||
|
$insertList[] = $track;
|
||||||
|
$totalTime += $track->length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach ($tracks as $track) {
|
||||||
|
$addTrack($track);
|
||||||
|
|
||||||
|
if ($isFull()) {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((!is_null($limit['items']) && $limit['items'] == count($insertList)) || $totalItems > 500 || $totalTime > $limit['time']) {
|
$sizeOfInsert = count($insertList);
|
||||||
break;
|
|
||||||
|
while (!$isFull() && $repeat && $sizeOfInsert > 0) {
|
||||||
|
Logging::debug('adding repeated tracks.');
|
||||||
|
Logging::debug('total time = ' . $totalTime);
|
||||||
|
$track = $insertList[array_rand(array_slice($insertList, 0, $sizeOfInsert))];
|
||||||
|
|
||||||
|
$addTrack($track);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $insertList;
|
return array_map(fn (Track $track) => ['id' => $track->id, 'length' => $track->length], $insertList);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1680,7 +1678,7 @@ SQL;
|
||||||
try {
|
try {
|
||||||
$out = $qry->setFormatter(ModelCriteria::FORMAT_ON_DEMAND)->find();
|
$out = $qry->setFormatter(ModelCriteria::FORMAT_ON_DEMAND)->find();
|
||||||
|
|
||||||
return ['files' => $out, 'limit' => $limits, 'repeat_tracks' => $repeatTracks, 'overflow_tracks' => $overflowTracks, 'count' => $out->count()];
|
return ['files' => $out, 'limit' => $limits, 'repeat_tracks' => $repeatTracks, 'overflow_tracks' => $overflowTracks, 'count' => $out->count(), 'sort_type' => $sortTracks];
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Logging::info($e);
|
Logging::info($e);
|
||||||
}
|
}
|
||||||
|
@ -1748,6 +1746,140 @@ SQL;
|
||||||
// smart block functions end
|
// smart block functions end
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class Track
|
||||||
|
{
|
||||||
|
public int $id;
|
||||||
|
|
||||||
|
public float $length;
|
||||||
|
|
||||||
|
public function __construct($id, $length)
|
||||||
|
{
|
||||||
|
$this->id = $id;
|
||||||
|
$this->length = $length;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return (string) $this->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Using a randomized greedy with local improvement approximation solution for the Subset Sum Problem.
|
||||||
|
*
|
||||||
|
* https://web.stevens.edu/algebraic/Files/SubsetSum/przydatek99fast.pdf
|
||||||
|
*/
|
||||||
|
class SSPSolution
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var Track[]
|
||||||
|
*/
|
||||||
|
public array $tracks;
|
||||||
|
|
||||||
|
public float $sum = 0.0;
|
||||||
|
|
||||||
|
public function __construct($tracks = [], $sum = null)
|
||||||
|
{
|
||||||
|
$this->tracks = $tracks;
|
||||||
|
if ($sum !== null) {
|
||||||
|
$this->sum = $sum;
|
||||||
|
} else {
|
||||||
|
foreach ($this->tracks as $track) {
|
||||||
|
$this->sum += $track->length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add(Track $track): SSPSolution
|
||||||
|
{
|
||||||
|
$new = $this->tracks;
|
||||||
|
$new[] = $track;
|
||||||
|
|
||||||
|
return new SSPSolution($new, $this->sum + $track->length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function replace(Track $old, Track $new): SSPSolution
|
||||||
|
{
|
||||||
|
return new SSPSolution(array_map(fn (Track $it) => $it === $old ? $new : $it, $this->tracks));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isCloseEnough(float $delta): bool
|
||||||
|
{
|
||||||
|
return $delta < 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function maxByOrNull(array $array, callable $callback)
|
||||||
|
{
|
||||||
|
$max = null;
|
||||||
|
$v = null;
|
||||||
|
foreach ($array as $item) {
|
||||||
|
$value = $callback($item);
|
||||||
|
|
||||||
|
if ($max === null || $v < $value) {
|
||||||
|
$max = $item;
|
||||||
|
$v = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Track[] $tracks
|
||||||
|
*/
|
||||||
|
public static function solve(array $tracks, float $target, int $trials = 50): SSPSolution
|
||||||
|
{
|
||||||
|
$best = new SSPSolution();
|
||||||
|
for ($trial = 0; $trial < $trials; ++$trial) {
|
||||||
|
shuffle($tracks);
|
||||||
|
$initial = array_reduce($tracks, function (SSPSolution $solution, Track $track) use ($target) {
|
||||||
|
$new = $solution->add($track);
|
||||||
|
if ($new->sum <= $target) {
|
||||||
|
return $new;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $solution;
|
||||||
|
}, new SSPSolution());
|
||||||
|
|
||||||
|
if (count($initial->tracks) == count($tracks)) {
|
||||||
|
return $initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
$acceptedItems = $initial->tracks;
|
||||||
|
shuffle($acceptedItems);
|
||||||
|
$localImprovement = array_reduce($acceptedItems, function (SSPSolution $solution, Track $track) use ($target, $tracks) {
|
||||||
|
$delta = $target - $solution->sum;
|
||||||
|
if (self::isCloseEnough($delta)) {
|
||||||
|
return $solution;
|
||||||
|
}
|
||||||
|
|
||||||
|
$replacement = self::maxByOrNull(
|
||||||
|
array_filter(
|
||||||
|
array_diff($tracks, $solution->tracks),
|
||||||
|
fn (Track $it) => $it->length > $track->length && $it->length - $track->length <= $delta,
|
||||||
|
),
|
||||||
|
fn (Track $it) => $it->length,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($replacement === null) {
|
||||||
|
return $solution;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $solution->replace($track, $replacement);
|
||||||
|
}, $initial);
|
||||||
|
|
||||||
|
if ($localImprovement->sum > $best->sum) {
|
||||||
|
$best = $localImprovement;
|
||||||
|
}
|
||||||
|
if ($best->sum === 0.0) {
|
||||||
|
return $best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class BlockNotFoundException extends Exception {}
|
class BlockNotFoundException extends Exception {}
|
||||||
class BlockNoPermissionException extends Exception {}
|
class BlockNoPermissionException extends Exception {}
|
||||||
class BlockOutDatedException extends Exception {}
|
class BlockOutDatedException extends Exception {}
|
||||||
|
|
Loading…
Reference in New Issue