Tilbake
5.4

5.4 Stykkvise og diskontinuerlige funksjoner

Representere stykkvise funksjoner (Heaviside, Piecewise) — ofte som en klasse med betinget logikk i `__call__`.

45 min
6 oppgaver
Stykkvisediskontinuerlige funksjoner
Din fremgang i kapitlet
0 / 6 oppgaver
Forkunnskaper — sist du var her. Du trenger tre ting: funksjonsklasse-malen fra kap. 5.1, if/elif/else og enumerate fra kap. 1.3, og — helt til slutt i kapitlet, som «bør kjenne til»-stoff — vektorisering fra kap. 4.2.

Her er den betingede logikken oppfrisket, i en helt vanlig funksjon:

def tariff(kwh):
    if kwh < 1000:
        return 0.4
    elif kwh < 5000:
        return 0.6
    else:
        return 0.9

for k in [500, 1000, 4999, 5000]:
    print(k, tariff(k))

Utskrift:

500 0.4
1000 0.6
4999 0.6
5000 0.9

Studer utskriften nøye, for den viser fellen du skal møte igjen om et øyeblikk: verdien 1000 gir 0.6, ikke 0.4. Grensen kwh < 1000 er eksklusiv — 1000 er ikke mindre enn 1000, så første gren hoppes over.

Det andre du trenger, er at enumerate(liste) gir deg indeks og verdi samtidig, i den rekkefølgen: for i, verdi in enumerate(...).

Løkke 1 — Funksjoner som hopper (~15 min)

Problemet: pumpa som slås på

En pumpe i et vannmagasin står stille fram til tidspunktet t=0t = 0, og går deretter med full kapasitet. Pådraget som funksjon av tiden er ikke en formel — det er to formler, med et hopp mellom dem:

H(t)={0t<01t0H(t) = \begin{cases} 0 & t < 0 \\ 1 & t \ge 0 \end{cases}

Slike funksjoner er overalt i naturvitenskapen: en dose gis på ett tidspunkt, en bryter slås på, en strømtariff endrer seg ved et forbrukstrinn, en kraft virker bare i et intervall.

Python har ingen egen syntaks for dem. Det du har, er if, og det er nok — enten i en vanlig funksjon eller, når parametrene skal kunne varieres, i en __call__.

Stykkvis definert funksjon
En funksjon som er gitt ved forskjellige uttrykk på forskjellige deler av definisjonsområdet. Skrivemåten i matematikk er en klammeparentes med én linje per intervall:

f(x)={0x<151x<32x3f(x) = \begin{cases} 0 & x < 1 \\ 5 & 1 \le x < 3 \\ 2 & x \ge 3 \end{cases}

Tre ting må være på plass for at definisjonen skal være entydig, og de er nøyaktig de tre du må kontrollere i koden din:

1. Hele definisjonsområdet er dekket — det finnes ingen xx som faller mellom to intervaller.
2. Ingen xx hører til to intervaller — grensene overlapper ikke.
3. Hver grense hører til én av sidene — det er derfor det står x<3x < 3 i den ene linjen og x3x \ge 3 i den neste.

I Python blir hver linje i klammeparentesen én gren i en if/elif/else, i samme rekkefølge.

Heaviside-funksjonen
Den enkleste stykkvise funksjonen: 0 for negative argumenter og 1 fra og med null.

H(x)={0x<01x0H(x) = \begin{cases} 0 & x < 0 \\ 1 & x \ge 0 \end{cases}

Den er oppkalt etter Oliver Heaviside og brukes til å slå noe på: ganger du en formel med H(tt0)H(t - t_0), virker formelen først fra tidspunktet t0t_0.

Merk konvensjonen: verdien i null er 1, ikke 0 og ikke 1/21/2. Andre fag setter H(0)=1/2H(0) = 1/2; i denne boka følger vi oppgavetekstens definisjon, og den sier H(0)=1H(0) = 1. Sier oppgaven noe annet, følger du oppgaven.

✏️Eksempel 1: Heaviside som funksjon og som klasse

Skriv Heaviside-funksjonen både som en vanlig funksjon H(x) og som en klasse Heaviside der påslagstidspunktet t0 gis i konstruktøren og har standardverdi 0. Skriv ut verdiene rundt hoppet.

def H(x):
    if x < 0:
        return 0
    else:
        return 1

class Heaviside:
    def __init__(self, t0=0):
        self.t0 = t0

    def __call__(self, t):
        if t < self.t0:
            return 0
        else:
            return 1

paa = Heaviside(2)

for x in [-1, -0.001, 0, 1]:
    print(x, H(x))
for t in [0, 1.999, 2, 5]:
    print(t, paa(t))

Utskrift:

-1 0
-0.001 0
0 1
1 1
0 0
1.999 0
2 1
5 1

Sammenlign de to. Den vanlige funksjonen er kortere, og du skal velge den når hoppet alltid ligger i null. Klassen er den du velger når hoppets plassering er en parameter — nettopp den situasjonen funksjonsklassen er laget for.

Se på tallene rundt hoppet: 0,001-0{,}001 gir 0, mens 00 gir 1. Det er <-grensen som avgjør, og det er verdt å sjekke nøyaktig dette punktet hver gang du har skrevet en stykkvis funksjon.

Legg også merke til at paa(1.999) er 0 og paa(2) er 1: klassen flytter hele hoppet til t0 uten at en eneste linje i logikken endres.

Uttelling: oppgaven ber ofte bare om funksjonen. Ber den om klassen, er de to poengbærende linjene self.t0 = t0 i konstruktøren og if t < self.t0 i kallmetoden — det siste med self. foran, som alltid.

`if/elif/else` for verdivalg

Mønsteret som oversetter en klammeparentes til kode. Grenene prøves ovenfra og ned, og den første som er sann, brukes — de andre hoppes over.

def f(x):
    if x < 1:
        return 0
    elif x < 3:
        return 5
    else:
        return 2

Fordi grenene prøves i rekkefølge, trenger du ikke skrive den nedre grensen på nytt: kommer du til elif x < 3, vet du allerede at x1x \ge 1. Det er derfor koden er kortere enn matematikken.

Rekkefølgen er dermed avgjørende. Snur du de to første grenene, får alle xx under 3 verdien 5, og den første grenen blir aldri brukt.

📝Oppgave 1

(Innstegsoppgave, sjanger A — kodesporing, altså «hva skriver programmet ut?».) Skriv utskriften nøyaktig, med linjeskift slik terminalen viser den.

def sats(x):
    if x < 0:
        return -1
    elif x < 10:
        return 0
    else:
        return 1

for x in [-0.5, 0, 9.99, 10, 100]:
    print(x, sats(x))

Løkke 2 — Piecewise: én klasse for alle trapper (~20 min)

Når antall intervaller er en parameter

if/elif/else fungerer så lenge du kjenner intervallene når du skriver koden. Men en strømtariff kan ha fem trinn i år og syv til neste år, og da vil du ikke skrive klassen om.

Løsningen er å gi funksjonsverdiene og grensene som lister til konstruktøren:

f(x)={a0x<b0a1b0x<b1anxbn1f(x) = \begin{cases} a_0 & x < b_0 \\ a_1 & b_0 \le x < b_1 \\ \vdots & \\ a_n & x \ge b_{n-1} \end{cases}

Med a = [0, 5, 2] og b = [1, 3] er dette nøyaktig trappa fra i stad. Legg merke til antallene: tre verdier krever to grenser.

Kallmetoden løper gjennom grensene ovenfra og ned og returnerer den første verdien som passer. Faller xx forbi alle grensene, er svaret den siste verdien.

`Piecewise`-klassen: `__init__(self, a, b)`

Konstruktøren tar to lister: a er funksjonsverdiene, b er grensene. Begge lagres som attributter, og ingenting mer skjer.

class Piecewise:
    def __init__(self, a, b):
        self.a = a
        self.b = b

Rekkefølgen i listene er bindende: a[i] er verdien på intervallet som slutter ved b[i], og listene skal være sortert stigende i xx.

Dette er funksjonsklasse-malen helt uendret — det faste (trappa) i konstruktøren, det variable (xx) i kallmetoden. Det nye er bare at «det faste» er to lister i stedet for to tall.

📜`Piecewise`-idiomet: løkka i `__call__`

Dette er det kanoniske mønsteret for stykkvise funksjoner i IN1900, og det er verdt å kunne utenat — det er fire linjer:

def __call__(self, x):
    for i, b_ in enumerate(self.b):
        if x < b_:
            return self.a[i]
    return self.a[-1]

Slik virker det, linje for linje:

1. enumerate(self.b) gir deg indeksen i og grensen b_ samtidig, ovenfra og ned.
2. if x < b_ spør om xx ligger under denne grensen. Er svaret ja, er vi i intervall nummer i.
3. return self.a[i] returnerer verdien for det intervallet — og return avbryter hele løkka, så ingen senere grense blir prøvd.
4. return self.a[-1] utenfor løkka er siste intervall: kom vi hit, var xx større enn eller lik alle grensene.

Understreken i b_ er bare et navnevalg for å unngå å bruke navnet b, som ofte er tatt. Den har ingen egen betydning i Python.

Den siste linjen er den som glemmes. Uten den returnerer metoden None for store xx-verdier, og programmet krasjer først senere — når noen prøver å regne med None.

`__call__`-løkka med `enumerate`

Hjertet i Piecewise. Løkka går gjennom grensene i rekkefølge, og return inne i løkka gjør at den stopper ved den første grensen som er større enn xx.

At return avbryter løkka, er det som gjør at koden svarer på samme spørsmål som if/elif/else: bare den første treffen gjelder.

Indeksen fra enumerate er bindeleddet mellom de to listene: grense nummer i hører til verdi nummer i. Bytter du om og skriver self.a[i+1], forskyver du hele trappa ett trinn.

`return self.a[-1]` — det siste intervallet

Linjen som står utenfor løkka og fanger opp alle xx som er større enn eller lik den siste grensen.

Negativ indeksering [-1] betyr «siste element», og det er nettopp poenget: du vet ikke hvor mange trinn trappa har, men du vet at siste verdi er den øverste.

Glemmer du linjen, returnerer metoden None for store xx. Symptomet er typisk TypeError: unsupported operand type(s) for *: 'NoneType' and 'float' et helt annet sted i programmet, og det er en ubehagelig feil å finne. Skriv linjen med én gang, sammen med løkka.

Antallsregelen: `len(a) == len(b) + 1`

Sammenhengen mellom listene: nn grenser deler tallinja i n+1n+1 intervaller, så det skal alltid være én funksjonsverdi mer enn antall grenser.

a = [0, 5, 2] og b = [1, 3]: tre verdier, to grenser. Det stemmer.

Dette er samme av-én-tenkning som i range og np.linspace (feil #1 i bokas feilkatalog — av-én i antall punkter). Har du like mange verdier som grenser, mangler du enten et intervall eller en grense, og feilen viser seg som IndexError eller som en verdi som aldri blir brukt.

En rask kontroll før du kjører: tell elementene i de to listene, og se at differansen er 1.

✏️Eksempel 2: `Piecewise` brukt på en strømtariff

Skriv den generelle klassen Piecewise med funksjonsverdier og grenser som lister, og bruk den på en tariff der prisen er 0,40 kr/kWh under 1000 kWh, 0,60 kr/kWh fra 1000 til under 5000, og 0,90 kr/kWh fra 5000 og opp.

class Piecewise:
    def __init__(self, a, b):
        self.a = a
        self.b = b

    def __call__(self, x):
        for i, b_ in enumerate(self.b):
            if x < b_:
                return self.a[i]
        return self.a[-1]

tariff = Piecewise([0.4, 0.6, 0.9], [1000, 5000])

for k in [0, 999, 1000, 4999, 5000, 20000]:
    print(k, tariff(k))

Utskrift:

0 0.4
999 0.4
1000 0.6
4999 0.6
5000 0.9
20000 0.9

Utskriften er hele kvitteringen på at koden er riktig, og den er verdt å lese grense for grense:

- 999 gir 0.4, 1000 gir 0.6 — grensen hører til intervallet over, som i definisjonen.
- 4999 gir 0.6, 5000 gir 0.9 — samme mønster ved neste grense.
- 20000 gir 0.9, og den verdien kommer fra return self.a[-1] utenfor løkka.

Klassen inneholder ikke ett tall om strøm. Den er ren struktur, og det er derfor samme fire linjer kan brukes på en tariff, en trappefunksjon eller et pådrag som slås av og på.

Uttelling: løkka med enumerate og return self.a[i] er hovedpoenget; return self.a[-1] utenfor løkka er det som skiller en komplett besvarelse fra en som virker «nesten alltid». Skriver du i stedet en if/elif/else med faste grenser, får du delvis uttelling — men oppgaven ba om den generelle klassen.

📝Oppgave 2

(Midtveisnivå, sjanger A — kodesporing.) Klassen Piecewise er som i eksempel 2. Hva skriver programmet ut?

class Piecewise:
    def __init__(self, a, b):
        self.a = a
        self.b = b

    def __call__(self, x):
        for i, b_ in enumerate(self.b):
            if x < b_:
                return self.a[i]
        return self.a[-1]

f = Piecewise([10, 20, 30, 40], [0, 5, 10])
print(f(-3))
print(f(0))
print(f(5))
print(f(9.5))
print(f(10))

📝Oppgave 3
Eksamensnivå, sjanger G

En trappefunksjon er 0 for x<1x < 1, 5 for 1x<31 \le x < 3 og 2 for x3x \ge 3.

a) Skriv en klasse Trapp der funksjonsverdiene og grensene gis som to lister i konstruktøren, og der kallet returnerer riktig verdi.

b) Lag instansen som svarer til trappa over, og skriv ut verdien i x=0x = 0, x=1x = 1, x=2,9x = 2{,}9, x=3x = 3 og x=7x = 7.

c) Hvor mange grenser trenger en trapp med fem verdier? Begrunn med én setning.

Løkke 3 — Diskontinuitet, og et vektorisert alternativ (~10 min)

Diskontinuitet

Et punkt der funksjonen hopper: verdien rett før grensen og verdien i grensen er forskjellige, uansett hvor nær du kommer.

Trappa vår hopper i x=1x = 1 og i x=3x = 3. Mellom hoppene er den konstant, og der er den helt ufarlig.

Diskontinuiteter har to praktiske konsekvenser du bør kjenne:

- Numerisk derivasjon virker ikke i hoppet. Formelen (f(x+h)f(xh))/(2h)(f(x+h)-f(x-h))/(2h) gir en enorm verdi som vokser når hh krymper, fordi teller er konstant og nevner går mot null.
- Plott ser rare ut. Plotter du en stykkvis funksjon med for få punkter, tegner plot en skrå strek gjennom hoppet, som om funksjonen var kontinuerlig. Det er en tegneartefakt, ikke funksjonen.

`np.where(betingelse, x, y)` (bør kjenne til)

Det vektoriserte alternativet: velger elementvis mellom to verdier ut fra en betingelse, og returnerer et helt array.

import numpy as np
t = np.linspace(-1, 1, 5)
print(np.where(t < 0, 0, 1))

Utskrift:

[0 0 1 1 1]

Dette er samme jobb som Heaviside gjør, men på alle punktene på én gang — nyttig når du skal plotte.

Dette er «bør kjenne til»-stoff (nivå 3). Klassevarianten med if er den som spørres om på eksamen, og den er også den du skal skrive. Grunnen til at np.where nevnes, er at en if-basert funksjon ikke virker på et array: du får ValueError: The truth value of an array with more than one element is ambiguous. Kjenner du den feilmeldingen igjen, vet du hva som skjedde.

📝Oppgave 4
Eksamensnivå, del-b

Du har trappa fra eksempel 2 som en Piecewise-instans, f = Piecewise([0, 5, 2], [1, 3]), og skal lage en verditabell over 401 punkter mellom 0 og 4 — den du trenger for å plotte trappa.

a) Du skriver x = np.linspace(0, 4, 401) og deretter y = f(x). Hva skjer, og hvorfor?
b) Skriv koden som bygger y riktig, slik at den blir like lang som x. Kontroller lengdene og verdiene i x=0x = 0, x=1x = 1 og x=3x = 3.
c) Et pådrag slås på ved t=2,5t = 2{,}5: verdien er 0 før og 1 fra og med. Skriv den vektoriserte varianten av nettopp den funksjonen for de fem tidspunktene 0, 1, 2, 3 og 4, på én linje. Hvorfor er ikke den varianten svaret sensor er ute etter for Piecewise?

✏️Eksempel 3: Stykkvis pådrag inne i en annen klasse (eksamensnivå)

I en dyrkningsforsøk tilføres et næringsstoff med rate 0 før t=2t = 2, rate 10 fra t=2t = 2 til t=8t = 8, og rate 4 etter det. Stoffet brytes samtidig ned med en avtakende faktor et/Te^{-t/T}. Skriv en klasse som kombinerer det stykkvise pådraget med nedbrytningen, og bruk Piecewise som en del av den.

class Piecewise:
    def __init__(self, a, b):
        self.a = a
        self.b = b

    def __call__(self, x):
        for i, b_ in enumerate(self.b):
            if x < b_:
                return self.a[i]
        return self.a[-1]

from math import exp

class Dosert:
    def __init__(self, doser, tider, halveringstid):
        self.paadrag = Piecewise(doser, tider)
        self.halveringstid = halveringstid

    def __call__(self, t):
        return self.paadrag(t)*exp(-t/self.halveringstid)

d = Dosert([0, 10, 4], [2, 8], 6)

for t in [0, 2, 5, 8, 12]:
    print(f'{t:3d} {d(t):8.3f}')

Utskrift:

  0    0.000
  2    7.165
  5    4.346
  8    1.054
 12    0.541

Se hva som skjer i konstruktøren: self.paadrag er en instans av Piecewise. Et attributt kan være et objekt, og det objektet er kallbart — så self.paadrag(t) inne i __call__ er et helt vanlig funksjonskall.

Dette er mønsteret du får bruk for i Del 7: en stykkvis kildefunksjon som del av høyresiden i en differensiallikning. Da er det nettopp self.paadrag(t) som står i formelen.

Utskriften viser hoppet: ved t=2t = 2 slås pådraget på, og verdien går fra 0 til noe positivt. Innenfor hvert intervall avtar verdien jevnt, fordi nedbrytningsfaktoren er kontinuerlig.

Uttelling: at pådraget gjenbrukes som en Piecewise-instans i stedet for å kopieres inn som if-er, er det sensor legger merke til. Skriver du i stedet if/elif direkte inne i __call__, er svaret riktig og uttellingen litt lavere — det viser mindre struktur.

📝Oppgave 5
Eksamensnivå, sjanger G

En kraft virker på en gjenstand bare i et tidsvindu: den er 0 før t=t1t = t_1, konstant F0F_0 mellom t1t_1 og t2t_2, og 0 etter t2t_2.

a) Skriv en klasse Pulskraft der F0F_0, t1t_1 og t2t_2 gis i konstruktøren, og der kallet gir kraften ved tiden t. Bruk if/elif/else.

b) Skriv den samme funksjonen på nytt, men nå ved å bruke den generelle Piecewise-klassen inne i din egen klasse. Hvilke to lister trenger du?

c) Skriv ut kraften ved t=0,1,2,5,6,9t = 0, 1, 2, 5, 6, 9 for F0=3F_0 = 3, t1=1t_1 = 1 og t2=6t_2 = 6, med begge variantene, og kontroller at de gir samme svar.

📝Oppgave 6
Eksamensnivå, sjanger O

Klassen under skal være den generelle stykkvise funksjonen, men den har tre feil. Finn dem, si hva hver av dem gjør, og skriv klassen riktig.

class Piecewise:
    def __init__(self, a, b):
        a = a
        b = b

    def __call__(self, x):
        for i, b_ in enumerate(self.b):
            if x <= b_:
                return self.a[i+1]

Bruk gjerne testen f = Piecewise([0, 5, 2], [1, 3]) og verdiene x=0,1,3,7x = 0, 1, 3, 7 når du forklarer.

API- og konstruksjonsliste

Begrepsbank

Begrepsbanken er flashcard-/repetisjonsstoff — den gjentar det du nettopp har lest. Hopp trygt over ved førstegangslesing; tidsanslaget for kapitlet gjelder kjernestoffet.

Trappefunksjon

En stykkvis funksjon som er konstant på hvert intervall. Grafen ser ut som en trapp: vannrette trinn med loddrette hopp mellom.

Strømtariffen, pulskraften og Heaviside er alle trappefunksjoner. Det er den typen Piecewise er laget for: klassen lagrer én verdi per intervall, ikke ett uttrykk per intervall.

Skal hvert intervall ha sin egen formel og ikke bare sin egen verdi, må du bruke if/elif/else med uttrykk i hver gren — eller lagre funksjoner i lista a og kalle self.a[i](x). Det siste er elegant, men oppgavene ber sjelden om det.

`enumerate(liste)`

Gir deg indeks og verdi samtidig, i den rekkefølgen: for i, verdi in enumerate(liste).

Rekkefølgen er verdt å dobbeltsjekke, for den er en fast eksamensfelle (#17 i bokas feilkatalog): det er (indeks, verdi), ikke (verdi, indeks).

I Piecewise er enumerate det som binder de to listene sammen. Uten indeksen måtte du telle selv med en teller før løkka, og det er nettopp den typen ekstra kode sensor ikke vil se.

Metoden som glemmer å returnere

En Python-funksjon som når slutten uten å møte en return, returnerer None. Det er ingen feil der og da — feilen kommer senere, når None brukes i et regnestykke.

I stykkvise funksjoner er dette den vanligste stille feilen: return self.a[-1] utenfor løkka mangler, og alt virker helt til noen kaller funksjonen med en stor xx-verdi.

Symptomet er en TypeError som nevner 'NoneType'. Ser du den, er spørsmålet alltid: hvilken gren i funksjonen min mangler en return?

Grensetesting

Vanen som fjerner nesten alle feil i stykkvise funksjoner: test i nøyaktig grensepunktet, og i punktet rett under.

For trappa med grense 3 betyr det å regne ut f(2,999)f(2{,}999) og f(3)f(3). Er begge som definisjonen sier, er den grensen riktig.

Tre tilfeldige testverdier avslører ikke en <-mot-<=-feil, fordi den bare gir gale svar i ett enkelt punkt. Grensetesting koster to sekunder og er den beste egensjekken i dette kapitlet — også på eksamen, der du regner den i hodet.

Hvorfor `if` ikke virker på et array

En if-setning krever ett sant/falskt svar. Gir du den et array med flere elementer, vet Python ikke om «arrayet er sant», og sier fra:

ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()

Det betyr at en stykkvis funksjon skrevet med if må kalles ett punkt av gangen, typisk i en løkke over punktene.

Vil du ha svaret for alle punktene på én gang — for eksempel til et plott — er np.where verktøyet. Det er «bør kjenne til»-stoff; eksamen spør etter if-varianten.

Et attributt kan være et kallbart objekt
self.paadrag = Piecewise(a, b) lagrer en hel instans i et attributt. Siden Piecewise har en __call__, kan du senere skrive self.paadrag(t) som et vanlig funksjonskall.

Dette er samme idé som Diff i kap. 5.1, der attributtet var en funksjon. Forskjellen er bare at objektet her er en instans av en av dine egne klasser.

Mønsteret er verdt å kjenne, for det er slik en stykkvis kildefunksjon settes inn i høyresiden av en differensiallikning i Del 7 — uten at ODE-koden trenger å vite noe om intervaller.

Repetisjon — fem spørsmål før du går videre

Dette kapitlet er skrevet av Anthropics toppmodeller (Claude Opus og Claude Fable) og er foreløpig ikke manuelt gjennomgått — kvalitetskontrollen gjøres av uavhengige KI-agenter, og innmeldte feil rettes fortløpende. Funnet en feil? Meld fra, så retter vi den. Les mer om hvordan innholdet lages.

Skolesaga er en uavhengig læringsressurs og er ikke tilknyttet eller godkjent av Universitetet i Oslo. Dette er ikke offisielt studiemateriell. Les mer.