Arduino poťouchlost – funkce map()

Opět jsem narazil na poťouchlost, kterou nepozorným uživatelům nachystali autoři knihoven Arduina.
Tentokrát se týká funkce map(), která, abych tak řekl jemně, „za jistých okolností dává nečekané výstupy“.

Funkce map() se používá pro přepočítání čísel z jednoho rozsahu do jiného. Zadáte hodnotu, původní rozsah hodnot, nový rozsah hodnot a zpátky dostanete hodnotu, která je nějak přepočítaná; syntaxe funkce je podle dokumentace map(value, fromLow, fromHigh, toLow, toHigh). V dokumentaci se dále píše: „Re-maps a number from one range to another. That is, a value of fromLow would get mapped to toLow, a value of fromHigh to toHigh, values in-between to values in-between, etc.“1

Takže když chcete čísla roztáhnout, třeba máte na vstupu čísla 0-10 a chcete výstup 0-100, tak napíšete vystup = map( vstup, 0, 10, 0, 100 ); a když byste si nechali vypsat všechny možnosti vstupu, dostali byste tyhle výsledky:

 i ... map( i, 0, 10, 0, 100 )
 0 ...   0
 1 ...  10
 2 ...  20
 3 ...  30
 4 ...  40
 5 ...  50
 6 ...  60
 7 ...  70
 8 ...  80
 9 ...  90
10 ... 100

Prostě minimum ze vstupu se přemapuje na minimum výstupu, maximum ze vstupu na maximum výstupu a zbytek se rozhodí mezi2.

Jde to i opačně? Přemapovat z většího rozsahu na menší? Třeba když máte vstup v rozsahu 1-10 a chcete to přepočítat na čísla 1-5? Zkusme to! Parametry budou tak, jak bychom čekali, tedy vystup = map( vstup, 1, 10, 1, 5);, a funkce bude vracet skutečně čísla v rozsahu 1-5:

 i ... map( i, 1, 10, 1, 5)
 1 ... 1
 2 ... 1
 3 ... 1
 4 ... 2
 5 ... 2
 6 ... 3
 7 ... 3
 8 ... 4
 9 ... 4
10 ... 5

To už je ale trochu divný (tři jedničky a jen jedna pětka?), nicméně to ještě nic není. Podívejte na začátek a konec sekvence při přepočítání z rozsahu 1-50 na 1-10:

 i ... map( i, 1, 50, 1, 10)
 1 ...  1
 2 ...  1
 3 ...  1
 4 ...  1
 5 ...  1
 6 ...  1
 7 ...  2
 8 ...  2
 9 ...  2
10 ...  2
11 ...  2
12 ...  3
...
39 ...  7
40 ...  8
41 ...  8
42 ...  8
43 ...  8
44 ...  8
45 ...  9
46 ...  9
47 ...  9
48 ...  9
49 ...  9
50 ... 10

Tady se vám doufám přinejmenším udělalo špatně, v lepším případě i zastavilo srdce, ale doufám, že vás stihli oživit, abyste si tenhle článek mohli přečíst až do konce.
Vidíme, že místo aby to bylo hezky tak, že 1-5 se přemapuje na 1, 6-10 na 2 a tak dál po pěticích až 46-50 na 10, tak né, to tak není. Ty bloky jsou po pěti nebo šesti číslech, takže na poslední výstupní číslo 10 už skoro nezbylo a přemapuje se na něj jediné číslo, a to vstupní maximum 50!

Ale pokračujme, držte si klobouky. Ono to je totiž ještě horší, když je výstupní rozsah menší, než vstupní. Třeba přepočet z 0-10 na 0-1, neboli když nějaký celý rozsah nechám přepočítat (rozdělit) jen na dvě hodnoty, tak místo abych dostal půlku nul a půlku jedniček, tak to vyjde takhle:

 i ... map( i, 0, 10, 0, 1 )
 0 ... 0
 1 ... 0
 2 ... 0
 3 ... 0
 4 ... 0
 5 ... 0
 6 ... 0
 7 ... 0
 8 ... 0
 9 ... 0
10 ... 1

To vůbec nevypadá hezky! Ba co hůř, některá čísla by mohla rozjet kampaň proti diskriminaci a mohlo by se stát, že to povede třeba k zákazu nazývání nuly nulou, jak se tak v poslední době stalo módou. Co se to stalo? Proč je to tak špatně? Ha ha, nenechte se zmást. Je to totiž v souladu s dokumentací, kde je uvedeno to, že krajní hodnoty se přemapují na krajní hodnoty a hodnoty mezi tím na hodnoty mezi tím. Takže to je formálně v pořádku34.

Jak se to mohlo stát? Je potřeba se podívat, jak se to vlastně počítá. Když si najdete v hloubi instalace Arduina zdroják WMath.cpp, kde je ta funkce map napsaná, tak je sice různý pro různé platformy (AVR, SAM, SAMD, ESP8266 a nejspíš i další, ty ale nemám nainstalované), ale tenhle přepočet je všude stejný a dělá se takhle:

long map(long x, long in_min, long in_max, long out_min, long out_max)
{
  return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min;;
}

Z téhle funkce je jasné, proč to je jak to je. Jádro výpočtu (i pudla) je v části (out_max - out_min) / (in_max - in_min), což je pro převádění z menšího rozsahu do většího OK, ale obráceně už ne. Pokud totiž chceme převádět z většího rozsahu do menšího tak, aby vstupní rozsah byl rozdělený na pokud možno stejně velké díly, tak těch dílů musí být tolik, kolik je různých hodnot na výstupu. A těch by muselo být o jedno víc, než out_max - out_min, jak se počítá ve vzorečku. Správně je pro tenhle směr out_max - out_min + 1.

Takže, pokud to chcete mít hezčí (ve smyslu „přemapovat znamená rozhodit to hezky pravidelně“), tak je léčba jednoduchá, jako ostatně u všeho, co se týče Arduina: udělejte si to sami. Rozdělíme výpočet na dvě části, jedna pořeší převod z většího do menšího, druhá naopak5:

long pokusMap(long x, long in_min, long in_max, long out_min, long out_max)
{
  if( in_max - in_min > out_max - out_min )
    return (x - in_min) * (out_max - out_min + 1) / (in_max - in_min + 1) + out_min;
  else
    return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min;
}

Podívejme se, jak teď vyjde převod z rozsahu 1-50 na 1-10 (pro srovnání uvádím jak původní map, tak tento pokusMap):

 i ... map ... pokusMap 
 1 ...  1 ...  1
 2 ...  1 ...  1
 3 ...  1 ...  1
 4 ...  1 ...  1
 5 ...  1 ...  1
 6 ...  1 ...  2
 7 ...  2 ...  2
 8 ...  2 ...  2
 9 ...  2 ...  2
10 ...  2 ...  2
11 ...  2 ...  3
12 ...  3 ...  3
13 ...  3 ...  3
14 ...  3 ...  3
15 ...  3 ...  3
16 ...  3 ...  4
17 ...  3 ...  4
18 ...  4 ...  4
19 ...  4 ...  4
20 ...  4 ...  4
21 ...  4 ...  5
22 ...  4 ...  5
23 ...  5 ...  5
24 ...  5 ...  5
25 ...  5 ...  5
26 ...  5 ...  6
27 ...  5 ...  6
28 ...  5 ...  6
29 ...  6 ...  6
30 ...  6 ...  6
31 ...  6 ...  7
32 ...  6 ...  7
33 ...  6 ...  7
34 ...  7 ...  7
35 ...  7 ...  7
36 ...  7 ...  8
37 ...  7 ...  8
38 ...  7 ...  8
39 ...  7 ...  8
40 ...  8 ...  8
41 ...  8 ...  9
42 ...  8 ...  9
43 ...  8 ...  9
44 ...  8 ...  9
45 ...  9 ...  9
46 ...  9 ... 10
47 ...  9 ... 10
48 ...  9 ... 10
49 ...  9 ... 10
50 ... 10 ... 10

To už je lepší, ale ještě to nefunguje hezky vždycky: zvažte, jak se to bude chovat při „překlápění“ rozsahů, např. map(x,1,10,10,1);. V takovém případě totiž jednak nemůžeme porovnávat ty velikosti rozsahů přímo, ale musíme porovnat jejich absolutní hodnoty6. Při výpočtu pak ještě buď čitatel nebo jmenovatel vyjdou jako záporné číslo, ale to je v pořádku, záporné znaménko nám pomůže při tom překlopení. Jenže přičtení jedničky by to u záporných čísel rozbilo, číselně by to bylo hodně špatně. Je potřeba to číslo zvětšit o 1 v absolutní hodnotě (tj. „dát ho dál od nuly“)! Takže v kladných číslech jedničku přičteme a v záporných jedničku odečteme:

long mojeMap(long x, long in_min, long in_max, long out_min, long out_max)
{
  long in_size = in_max - in_min;
  long out_size = out_max - out_min;
  if( abs(in_size) > abs(out_size) )
  {
    if( in_size < 0 ) in_size--; else in_size++;
    if( out_size < 0 ) out_size--; else out_size++;
  }
  return (x - in_min) * (out_size) / (in_size) + out_min;
}

A když to uděláme takhle, tak to už bude v pořádku! 🙂

Podívejme se ještě pro jistotu na výsledek přemapování z rozsahu 1 až 50 na 10 až 1, jak to hezky vyjde7:

 i ... map ... pokusMap ... mojeMap
 1 ... 10 ... 10 ... 10
 2 ... 10 ... 10 ... 10
 3 ... 10 ... 10 ... 10
 4 ... 10 ... 10 ... 10
 5 ... 10 ... 10 ... 10
 6 ... 10 ... 10 ...  9
 7 ...  9 ... 10 ...  9
 8 ...  9 ...  9 ...  9
 9 ...  9 ...  9 ...  9
10 ...  9 ...  9 ...  9
11 ...  9 ...  9 ...  8
12 ...  8 ...  9 ...  8
13 ...  8 ...  9 ...  8
14 ...  8 ...  8 ...  8
15 ...  8 ...  8 ...  8
16 ...  8 ...  8 ...  7
17 ...  8 ...  8 ...  7
18 ...  7 ...  8 ...  7
19 ...  7 ...  8 ...  7
20 ...  7 ...  7 ...  7
21 ...  7 ...  7 ...  6
22 ...  7 ...  7 ...  6
23 ...  6 ...  7 ...  6
24 ...  6 ...  7 ...  6
25 ...  6 ...  7 ...  6
26 ...  6 ...  6 ...  5
27 ...  6 ...  6 ...  5
28 ...  6 ...  6 ...  5
29 ...  5 ...  6 ...  5
30 ...  5 ...  6 ...  5
31 ...  5 ...  6 ...  4
32 ...  5 ...  6 ...  4
33 ...  5 ...  5 ...  4
34 ...  4 ...  5 ...  4
35 ...  4 ...  5 ...  4
36 ...  4 ...  5 ...  3
37 ...  4 ...  5 ...  3
38 ...  4 ...  5 ...  3
39 ...  4 ...  4 ...  3
40 ...  3 ...  4 ...  3
41 ...  3 ...  4 ...  2
42 ...  3 ...  4 ...  2
43 ...  3 ...  4 ...  2
44 ...  3 ...  4 ...  2
45 ...  2 ...  3 ...  2
46 ...  2 ...  3 ...  1
47 ...  2 ...  3 ...  1
48 ...  2 ...  3 ...  1
49 ...  2 ...  3 ...  1
50 ...  1 ...  3 ...  1

UF!

Tak a ještě na závěr dvě hádanky či spíše řečnické otázky, prozradím vám to:

1. Hádejte, co to udělá, když vstupní hodnota k přemapování bude mimo zadaný rozsah vstupních hodnot.
Odpověď: Nic zvláštního, prostě se to spočítá. Přepočte se to, ty rozsahy tam jsou jen aby určily poměr mezi vstupem a výstupem. Akorát teda původní funkce map to někdy udělá tak nějak baj vočko (například map(500,1,100,1,10); nevrátí 50, ale 46 …).

2. Hádejte, co to udělá, když jako vstupní rozsah zadáte pro minimum i maximum stejná čísla, takže by se ve formulce mělo dělit nulou.
Odpověď: Nic zvláštního, prostě se to spočítá. I když nás ve škole učili, že nulou se dělit nedá, tak stejně jako u jiných věcí, co nás ve škole učili, v praxi to je často jinak a nulou se klidně dělí. V tomto případě překladač gcc (který Arduino k překladu používá) pro dělení vstupy neošetřuje a vyrobí nějaký kód co něco spočítá8, takže při dělení nulou vám prostě něco vyjde. Třeba 1. Nebo -1. Ale o tom zase jindy, až rozdejcháte tohle 🙂 .


Poznámky:

  1. Překlad: Přemapuje číslo z jednoho rozsahu do druhého. To znamená, že hodnota fromLow bude přemapována na toLow, hodnota fromHigh na toHigh, hodnoty mezi tím na hodnoty mezi tím atd.)
  2. V tomhle konkrétním příkladě bych to vynásobil deseti, ale na nějakém méně triviálním příkladu by to nebylo tak hezky hned vidět. Například zkuste map( i, 1, 10, 1, 100 );, jehož výsledky možná vypadají na první pohled trochu překvapivě, ale na druhý pohled to je správně, intervaly jsou hezky pravidelné:

     i ... map( i, 1, 10, 1, 100)
     1 ...   1
     2 ...  12
     3 ...  23
     4 ...  34
     5 ...  45
     6 ...  56
     7 ...  67
     8 ...  78
     9 ...  89
    10 ... 100

  3. Když dokumentace říká, že to nějak funguje a funguje to tak i ve skutečnosti, tak to není bug, ale fíčura.
  4. Pro ty, co by to stejně radši opravili, mám špatnou zprávu: máme smůlu, naděje na změnu v podstatě není, protože podle oficiálního vyjádření by to mohlo změnit chování už existujících Arduino skriptů a to by uživatelé neunesli.
  5. Pozor, ještě to není správně, ale to za chvilku opravíme
  6. Rozsah 1000..1 je jistě větší než rozsah 1..10, ale podle programu porovnáváme 1-1000 a 10-1 a to je obráceně, než bychom chtěli: 1-1000 < 10-1 protože -999 je menší, než +9
  7. „Hezký“ je až poslední sloupeček, ale pro ukázku se podívejte i na to, jakým způsobem jsou špatně ty předchozí
  8. Ono totiž na základních Arduinech, tedy na platformě AVR, vůbec dělení není a pokud ho ve svém skriptu použijete, tak vám ho překladač nasimuluje, asi tak jako když dělíte čísla ručně tužkou na papíře.