czech english

Řídící software

jak naprogramovat robota a nezbláznit se u toho

Pro Ester jsme se letos rozhodli kompletně přebudovat software na základě zkušeností z minulých let tak, aby bylo umožněno snadnější modifikování a testování různých strategií. Řízení robota je do značné míry hlavně o detekování stavu robota a jeho okolí (např. jsem před brankou a mám míček) a aplikování příslušného řešení (vystřelit). Klasickým nástrojem pro programování tohoto druhu aplikací je FSM (Finite State Machine — konečný automat), které se nám ale jeví jako velmi těžko udržovatelné a vůbec tak nějak neohrabané. Jde to udělat lépe?

FSM — konečné automaty

Základní schéma, které jsme ve větší či menší míře používali ve všech našich robotech od Aleny dále vypadá následovně:
while (true)
{
    sendReceive(commandStruct, stateStruct);
    switch(state)
    {
        case INIT:               state = init(stateStruct); break;
        case GOING_TO_SUN:       state = goingToSun(stateStruct); break;
        case SUN_REACHED:        state = sunReached(stateStruct); break;
        case DEPLOYING_SUN_FLAG: state = deployingSunFlag(stateStruct); break;
        …
    }
}
Je to řešení jednoduché, přímočaré a velmi jednoduše se vysvětluje, jak funguje. Základem je nekonečná smyčka, ve které alternujeme komunikaci s robotem a zpracování příchozích dat.
Komunikace s robotem se po počátečních experimentech s asynchronním zasíláním různých typů zpráv po seriální lince ustálila na synchronní komunikaci pomocí dvou základních struktur. V nejjednodušším případě struktura commandStruct obsahuje stav, jaký si přejeme na výstupních pinech řídícího mikroprocesoru a stateStruct reprezentuje stav vstupních zařízení (A/D převodníky a digitální vstupy).
Zpracování příchozích dat odpovídá vnitřnímu stavu, v jakém se robot nachází. Například ve stavu INIT robot čeká na vytažení startovacího kabelu a v takovém případě začne měřit čas a přejde do stavu GOING_TO_SUN. V každém dalším stavu pak kontroluje, zda neuplynul celkový čas 90s a podmínky specifické pro daný stav, které by způsobily přechod do některého z jiných stavů.
Na první pohled jednoduché, přehledné, pochopitelné. V čem je tedy problém? Takto implementované FSM se hodí na systém, který lze zanalyzovat a přesně specifikovat potřebné stavy i přechody mezi nimi. Zkušenosti ukazují, že mobilní robot takovým systémem není. Je totiž mnoho činností, které jdou jakoby napříč stavy. Pokud se takové činnosti (funkce) snažíme postihnout ve výše navržené struktuře typicky nám exploduje počet stavů nad únosnou míru, přičemž mnoho stavů si je velmi podobných. Takto strukturovaný kód se rychle stává velmi nepřehledným a jen velmi těžko udržovatelným či rozšiřitelným. Hlavním důvodem je duplicita kódu (například v případě Aleny je jízda ke slunci skoro totožná s jízdou k další planetě apod.).
Jak je patrné z uvedených příkladů, dalším velmi důležitým problémem je vlastní definice jednotlivých stavů. Pokud existují stavy, do kterých vede více hran, je třeba zajistit, aby pro všechny hrany platily stejné, nebo alespoň podobné podmínky. Například ve stavu odpovídajícímu umisťování vlaječky na slunce je třeba, aby se robot nacházel u slunce (jinak daná akce nemá smysl). Díky typické rozsáhlosti kódu je to úkol téměř nadlidský. Struktura kódu tomuto totiž vůbec nenapomáhá, ba naopak. Teoreticky je možné přejít z každého stavu do každého (úplný graf) a je tedy pouze na programátorovi (a jeho disciplíně), aby tyto podmínky zaručil. Co se stane, má-li na kódu pracovat několik prográmátorů zároveň nebo má-li na práci jednoho navázat další, si asi dokážete představit sami.

Pokus o nápravu č.1

První pokus o znovuzískání kontroly nad naším kódem byla implementace vnořených FSM. Motivací bylo, že je typicky celkem jedno, jestli robot jede ke slunci, k puku nebo k brance, a že tedy jízda na cíl může být samostatná dovednost, které se bude moci použít v několika různých případech. Stejně tak některé jiné činnosti.
Hlavní smyčka potom může vypadat třeba takto:
while (true)
{
    sendReceive(commandStruct, stateStruct);
    switch(state)
    {
        case AWAITING_START:
            controler.stop();
            if (stateStruct.startCable)
            {
                timeLeft = 90.0;
                state = PLAYING;
            }
            break;

        case PLAYING:
            controler.execute();
            timeLeft -= stateStruct.dt;
            if (timeLeft <= 0)
            {
                state = GAME_OVER;
            }
            break;

        case GAME_OVER:
            controler.stop();
            if (currentSpeed == 0)
            {
                state = AWAITING_START;
                controller.reset();
            }
            break;
    }
}
Je vidět, že tímto způsobem se nám podařilo relativně hezky ošetřit start robota startovacím kabelem a jeho zastavení po 90s (po zastavení se řídící program zresetuje a připraví pro další zápas zejména pro účely testování). Kód pro tuto činnost je oddělen od zbytku řídícího systému, snadno se vizualizuje jeho činnost i jeho korektnost. Řídící systém je celý uschován v objektu controller.
Podobným způsobem můžeme oddělit třeba FSM implementující jízdu na cíl:
int goTo(goal)
{
    offset = calculateOffsetToGoal(goal);

    switch (action(offset))
    {
        case TURN:        action = &turn ;       return Command::IN_PROGRESS;
        case STRAIGHT:    action = &straightFW ; return Command::IN_PROGRESS;
        case STOP:        action = &stop ;       return Command::IN_PROGRESS;
        case IN_PROGRESS:                        return Command::IN_PROGRESS;
        case RESULT_OK:                          return Command::RESULT_OK;
    }
    return Command::IN_PROGRESS;
}
Tímto způsobem jsme dosáhli jednoho z našich cílů — podařilo se nám kód rozdělit na menší části, na kterých potom mohou pracovat různí lidé (anglicky bychom řekli, že jsme snížili coupling). Stále nám ale do jisté míry přetrvává problém s konzistencí jednotlivých stavů. Vnořené FSM také potřebují být v určitých případech (re)inicializovány a typicky očekávají, že se jejich tzv. akční procedura bude volat periodicky v každém kroku dokud samy neřeknou „dost”, a že mají úplnou kontrolu nad příslušnou částí robota (např. pro goTo(goal) to znamená kontrolu nad motory řídícími pohyb robota). Tyto požadavky stále zůstávají na bedrech programátorů, i když jsou částečně zjednodušeny modularizací kódu. Tímto způsobem byla naprogramována Dana.

Co na to konkurence?

Protože jsme stále ještě nebyli spokojeni se strukturou našeho kódu, zajímalo nás, jak tyto problémy řeší konkurence. Podařilo se nám najít následující projekty:

PlayerStage — robotický operační systém

Tento softwarový balík poskytuje řadu abstraktních interface pro nejrozšířenější hardwarové platformy mobilních robotů. Je vybudován na třech principech: (a) rozhraní (interface), (b) zařízení (device), (c) ovladač (driver). V zásadě odpovídá vrstvě, která dělá
základní postprocessing (analýzu) vstupních dat do nejaké hardwarově nezávislé podoby. Pro řízení robota poskytuje obdobnou funkcionalitu transformací obecných příkazů do hardwarově závislé podoby. Pro jednoduchost tento systém můžeme přirovnat k operačnímu systému.
K dispozici je několik aplikačních knihoven v několika různých jazycích, které ale nekladou nějaká zásadní omezení na strukturu klientského kódu. Nepodařilo se nám najít žádný rozsáhlejší příklad použití, přestože se kolem projektu pohybuje řada vědců (zejména amerických), kteří údajně využívají tento systém při implementaci svých algoritmů.

CARMEN — soubor modulů pro všechny aspekty řízení a navigace

Carnegie Mellon Robot Navigation Toolkit se skládá z několika modulů, které mezi sebou komunikují pomocí IPC. IPC je balík umožňující komunikaci mezi moduly pomocí TCP/IP sockets. Komunikace probíhá zasíláním zpráv a umožňuje publish/subscribe (modul generuje data, o která si může zažádat modul jiný bez jeho vědomí) i query/response (modul generuje dotaz a čeká na odpověd) architekturu. Hlavní vlastností omezující tvorbu aplikací je pravděpodobně potřeba konstruovat každý modul jako samostatný proces a s tím spojené obtíže se spouštěním a laděním takovéto aplikace. Za odměnu ale získáme aplikaci z níž každý modul může běžet na jiném počítači.

Tekkotsu — specifické pro AIBO

Tekktosu je velmi úzce specializovaný balík pro robotického psa AIBO. K dispozici máme vše, co je třeba na jeho ovládání, a předpokládané použití spočívá v rozšíření předdefinovaný tříd o specifické úkoly či algoritmy (tutorial, overview). Specifických rošíření dosáhneme předefinováním virtuálních funkcí v kombinaci s (obdobně jako v IPC) notifikacemi v podobě zpráv (registrací tzv. handler funkcí).

Robocode — simulace tankové bitvy v Javě

Robocode se od výše zmíněných programových balíků liší zejména tím, že se zabývá pouze simulovanými roboty. Základní API, které je k dispozici pro programování robotů, má synchronní charakter. Více asi napoví příklad:
// A basic robot that moves around in a square
public void run() {
    turnLeft(getHeading());
    while(true) {
        ahead(100);
        turnRight(90);
    }
}
Mimo tohoto synchronního API nabízí Robocode ještě takzvané AdvancedRobot API, které je více low-level a dává nahlédnout pod pokličku celému systému. API funkce ahead(int) by pomocí tohoto low-level API mohla být implementována následovně:
public void ahead(int dist) {
    setAhead(dist);
    while(dist– > 0)
        execute();
    setStop();
    execute();
  }
}
Metoda execute() zde slouží jako naše sendReceive(commandStruct, stateStruct) a setAhead(dist) si lze představit jako nastavení výkonu motorů. Synchronní API je pak vlastně naše původní FSM pouze otočená tak nějak naruby.

„Lessons learned”

Co jsme se tedy naučili od našich kolegů? Například že velmi důležitý je preprocessing příchozích i odchozích dat do hw nezávislé podoby. Přestože to nemusí být na první pohled patrné, je to celkem náročný úkol (Player v podstatě řeší pouze toto a i v Ester to nakonec byly asi dvě třetiny kódu). V důsledku ale zjednodušuje jak aplikační kód tak i rozhraní pro simulaci.
Dalším společným jmenovatelem všech projektů je snaha o modularizaci kódu se striktní separací a přesnou definicí komunikačního rozhraní, které bývá nečastěji implementováno různými metodami zasílání zpráv.
V neposlední řadě stojí za zmínku i synchnronní API Robocode, které nás zaujalo svojí jednoduchostí a přímočarostí. Jeho designu pravděpodobně značně napomohlo to, že bylo navrhováno pro simulované roboty a nezabývá se tedy takovými „detaily” jako jsou motory či tiky enkodérů, což v důsledku pomáha i snadnější simulaci takto ovládaného robota.

Pokus o nápravu č.2 — Ester

Pomocí vnořených FSM se nám podařilo dosáhnout jisté modularizace kódu tak, aby byl přehlednější a mohlo na něm zároveň pracovat více lidí. Stále se nám ale nepodařilo vyrovnat se s fundamentálním problémem FSM implementovaných pomocí konstrukce switch(state), kterým je fakt, že stále teoreticky můžeme libovolně přecházet mezi stavy a vizualizace těchto přechodů není jednoduchá. Také správná inicializace všech vnořených FSM stále leží na bedrech (a pozornosti) programátorů. To vše vede k obtížím při rozšiřování, ale i pouhém udržování kódu.
Při důkladnějším pohledu na switch-based FSM zjistíme, že se vlastně pouze velmi podivuhodným způsobem snažíme implementovat věc, která je nám programátorům běžná a vlastně se s ní setkáváme dnes a denně (pokud tedy neprogramujete pouze v prologu ). Příklad opět pomůže. Jak by asi vypadala implementace robota jezdícího do čtverce pomocí konstrukce switch(state)?
state = INIT;

while (true)
{
    sendReceive(commandStruct, stateStruct);
    switch(state)
    {
        case INIT:
            if (getAbsHeading() == 0)
            {
                state = STRAIGHT;
                dist = 100;
                break;
            }
            setLeft();
            break;

        case STRAIGHT:
            if (dist == 0)
            {
                state = TURN;
                angle = 90;
                break;
            }
            dist-@-;
            setAhead();
            break;

        case TURN:
            if (angle == 0)
            {
                state = STRAIGHT;
                dist = 100;
                break;
            }
            angle-@-;
            setLeft();
            break;
    }
}
Pro jednoduchost jsme vzdálenost i úhel definovali pouze počtem kroků. Slovy bychom činnost robota mohli popsat asi takto: na počátku se otoč tak, aby tvá absolutní orientace byla 0 a potom jeď vždy 100 tiků rovně a 90 tiků doleva. Už i na takto jednoduchém příkladě vidíme, že to, co lze slovy popsat velmi jednoduše, je trasformováno do tvaru, který původní zadání moc nepřipomíná. Oproti tomu při použití synchronního API Robocode je algoritmus zakódován přímočaře:
// A basic robot that moves around in a square
public void run() {
    turnLeft(getHeading());
    while(true) {
        ahead(100);
        turnRight(90);
    }
}
I tento kód je ve svém důsledku FSM, pouze nám ale vydatně pomáhá kompilátor, aby kód zůstal čitelný. Místo stavové proměné state zde máme IP (instruction pointer), který ukazuje na právě vykonávanou instrukci a zásobník pro volání funkcí (v důsledku implementující vnořené FSM).
Zásadní výhodou tohoto řešení ovšem je to, že většinu věcí, na které musel dříve dávat pozor programátor, za nás nyní obstará kompilátor. Například máme automaticky zajištěno, že se během pohybu robota nedostaneme znovu do inicializační části (odpovídá stavu INIT) i to, že při opětovném volání funkce run() bude tato bezpodmínečně vykonána.
Také stojí za to zmínit jednoduchost a přehlednost kódu. Ta se ještě výrazněji projeví v případě, kdy je třeba implementovat složitější algoritmus obsahující několik do sebe různě vnořených cyklů, podmínek a složitějších funkcí typu ahead() nebo turnRight().
Celý algoritmus řídící Ester na soutěži Eurobot 2004 lze potom popsat až takto jednoduchým kódem:
virtual int main()
{
    mapPalms();
    while (true)
    {
        try
        {
            BallDetect bd;
            EnemyDetect ed;
            while (true)
            {
                searchBall();
                randomMove();
            }
        }
        catch (BallDetect::done)  { shootBall();  }
        catch (EnemyDetect::done) { randomMove(); }
        catch (std::runtime_error){ randomMove(); }
    }
    return 0;
}
Ester nejprve najde pozice palem. To v současné době dělá přejetím hřiště ze startovní pozice po jeho delší straně a monitorováním vzdálenosti, kterou detekuje na stranu pohlížející infra range finder Sharp. Potom si vytvoří dva detektory, které se v systému zaregistrují, aby byly volány v každém kroku a vydá se hledat míčky. Pokud nenajde v blízkosti žádný míček, vybere si nový náhodný cíl na hřišti a pokračuje v hledání. Tyto dvě činnosti se alternují tak dlouho, dokud není nalezen míček a nebo dokud Ester nenarazí na soupeře. Nalezený míček vstřelí do branky, zatímco nalezenému nepříteli se vyhne. V obou případech pokračuje opět hledáním dalšího míčku.

Závěr

Řídící systém Ester by v případě implementace pomocí klasické FSM byl neúnosně složitý (v řádu až několika stovek stavů v několika desítkách FSM). Díky využití klasických postupů, na které je každý programátor zvyklý (konstrukce if, else, while nebo volání funkcí) pro řízení robota pomocího synchronního API jsme oproti minulým letům dosáhli výrazného zvýšení efektivity práce, což nám v důsledku umožnilo vypracovat spolehlivější řídící systém, který ve spojení se spolehlivým hardware zajistil Ester 4. místo.
Samozřejmě, že i aktuální systém má svá úskalí. Tak, jak jsme ho doposud popisovali, by například neumožňoval paralelní řízení pohybu robota a ovládání dalších periferií (v případě Ester válce pro manipulaci s míčky). To jsme vyřešili použitím několika vláken výpočtu, což samo o sobě přináší nemalé komplikace při přístupu ke sdíleným datům a synchronizaci vůbec.
Pro implementaci nastíněného API je třeba si připravit relativně hodně podpůrného kódu (zejména pro obsluhu více vláken). Ovšem po zvládnutí počátečních obtíží se podle našeho názoru taková investice rozhodně vyplatí.
Máte-li jakékoli dotazy či připomínky – kontaktujte nás. Rádi vám odpovíme.