bae705aa97
- Add NFC ePassport roadmap (ICAO 9303, eIDAS) - Add TensorFlow.js edge face detection (BlazeFace) - Add structured audit logger (GDPR-compliant) - Risk scoring support Part of KYC Apple Native UX v1.1.0
687 lines
19 KiB
Markdown
687 lines
19 KiB
Markdown
# IOM Implementationsplan — quiXzoom Fas 1
|
|
|
|
> Konkret plan för att bygga Infrastructure Object Model
|
|
> Status: Aktiv | 2026-06-26
|
|
|
|
---
|
|
|
|
## Vecka 1-2: Fas 1 Grund (Lager 1, 2, 5, 6)
|
|
|
|
### Dag 1-2: Taxonomi (Lager 1)
|
|
|
|
**Uppgift:** Definiera domäner för quiXzoom Fas 1
|
|
|
|
```bash
|
|
# Skapa taxonomi-fil
|
|
cat > iom_taxonomy_v1.json << 'EOF'
|
|
{
|
|
"version": "1.0.0",
|
|
"domains": {
|
|
"BYG": {
|
|
"name": "Byggnad",
|
|
"systems": {
|
|
"FAC": {
|
|
"name": "Fasad",
|
|
"objects": {
|
|
"WIN": {"name": "Fönster", "components": ["GLA", "FRM", "SIL"]},
|
|
"PAN": {"name": "Fasadpanel", "components": []},
|
|
"ENT": {"name": "Entré", "components": ["DOR", "TRP", "CAN"]}
|
|
}
|
|
},
|
|
"ROF": {
|
|
"name": "Tak",
|
|
"objects": {
|
|
"SUR": {"name": "Takyta", "components": []},
|
|
"GUT": {"name": "Ränna", "components": []},
|
|
"CHI": {"name": "Skorsten", "components": []}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"BEL": {
|
|
"name": "Belysning",
|
|
"systems": {
|
|
"STR": {
|
|
"name": "Gatlykta",
|
|
"objects": {
|
|
"LED": {"name": "LED-armatur", "components": ["FND", "POL", "DRV"]},
|
|
"SON": {"name": "Natrium", "components": ["FND", "POL", "BAL"]}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"COM": {
|
|
"name": "Kommersiellt",
|
|
"systems": {
|
|
"DIS": {
|
|
"name": "Butiksfront",
|
|
"objects": {
|
|
"SGN": {"name": "Skylt", "components": []},
|
|
"WIN": {"name": "Skyltfönster", "components": ["GLA", "FRM"]}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
EOF
|
|
```
|
|
|
|
**Validering:**
|
|
- 3 bokstäver per nivå
|
|
- Unika inom förälder
|
|
- Engelska förkortningar
|
|
|
|
---
|
|
|
|
### Dag 3-4: GOID (Lager 2)
|
|
|
|
**Uppgift:** Bygg ID-generering
|
|
|
|
```python
|
|
# goid_generator.py
|
|
import hashlib
|
|
import time
|
|
from typing import Optional
|
|
|
|
class GOIDGenerator:
|
|
"""Genererar globala objekt-ID:n för IOM"""
|
|
|
|
def __init__(self, domain: str, system: str, subsystem: str, obj_type: str):
|
|
self.prefix = f"{domain}-{system}-{subsystem}-{obj_type}"
|
|
self.sequence = 0
|
|
|
|
def generate(self, location_hash: Optional[str] = None) -> str:
|
|
"""Generera unikt GOID"""
|
|
self.sequence += 1
|
|
|
|
# Format: DOM-SYS-SUB-OBJ-SEQ
|
|
# Exempel: BYG-FAC-WIN-GLA-0001
|
|
goid = f"{self.prefix}-{self.sequence:04d}"
|
|
|
|
# Om plats-hash finns, lägg till för extra unikhet
|
|
if location_hash:
|
|
short_hash = hashlib.md5(location_hash.encode()).hexdigest()[:4]
|
|
goid = f"{goid}-{short_hash}"
|
|
|
|
return goid
|
|
|
|
def validate(self, goid: str) -> bool:
|
|
"""Validera GOID-format"""
|
|
parts = goid.split('-')
|
|
if len(parts) < 5:
|
|
return False
|
|
|
|
# Validera att alla delar är 3 bokstäver förutom sekvens
|
|
for part in parts[:-1]:
|
|
if len(part) != 3 or not part.isalpha():
|
|
return False
|
|
|
|
# Validera sekvens
|
|
try:
|
|
int(parts[-1])
|
|
except ValueError:
|
|
return False
|
|
|
|
return True
|
|
|
|
# Exempel
|
|
if __name__ == "__main__":
|
|
gen = GOIDGenerator("BYG", "FAC", "WIN", "GLA")
|
|
print(gen.generate()) # BYG-FAC-WIN-GLA-0001
|
|
print(gen.generate()) # BYG-FAC-WIN-GLA-0002
|
|
```
|
|
|
|
---
|
|
|
|
### Dag 5-7: Observationer (Lager 6)
|
|
|
|
**Uppgift:** Bygg observation-API
|
|
|
|
```python
|
|
# observation_api.py
|
|
from datetime import datetime
|
|
from typing import List, Dict, Optional
|
|
from pydantic import BaseModel
|
|
|
|
class Finding(BaseModel):
|
|
type: str
|
|
code: str # Felkod från Lager 7
|
|
description: str
|
|
measurement: Optional[str] = None
|
|
confidence: float # 0.0 - 1.0
|
|
|
|
class Media(BaseModel):
|
|
type: str # image, video, depth_map
|
|
url: str
|
|
timestamp: datetime
|
|
geotag: Optional[Dict] = None
|
|
|
|
class Observation(BaseModel):
|
|
id: str
|
|
timestamp: datetime
|
|
object_goid: str
|
|
observer: str # zoomer:id eller sensor:id
|
|
findings: List[Finding]
|
|
media: List[Media]
|
|
|
|
# AI-analys
|
|
ai_model: Optional[str] = None
|
|
overall_condition: Optional[int] = None # 1-5
|
|
recommended_action: Optional[str] = None
|
|
next_observation_due: Optional[datetime] = None
|
|
|
|
class Config:
|
|
schema_extra = {
|
|
"example": {
|
|
"id": "OBS-2026-0012847",
|
|
"timestamp": "2026-06-26T09:15:00Z",
|
|
"object_goid": "BYG-FAC-WIN-GLA-2847",
|
|
"observer": "zoomer:anna_k",
|
|
"findings": [
|
|
{
|
|
"type": "dirt_accumulation",
|
|
"code": "2100",
|
|
"description": "Smuts på fönster",
|
|
"confidence": 0.94
|
|
}
|
|
],
|
|
"media": [
|
|
{
|
|
"type": "image",
|
|
"url": "https://.../img_2847.jpg",
|
|
"timestamp": "2026-06-26T09:15:03Z"
|
|
}
|
|
],
|
|
"ai_model": "infrastructure-v3.2",
|
|
"overall_condition": 3,
|
|
"recommended_action": "schedule_cleaning"
|
|
}
|
|
}
|
|
|
|
class ObservationStore:
|
|
"""Lagra och hämta observationer"""
|
|
|
|
def __init__(self, db_connection):
|
|
self.db = db_connection
|
|
|
|
def create(self, obs: Observation) -> str:
|
|
"""Spara observation"""
|
|
# Generera ID om inte angivet
|
|
if not obs.id:
|
|
obs.id = f"OBS-{datetime.now().year}-{self._next_sequence():07d}"
|
|
|
|
# Spara i databas
|
|
self.db.observations.insert_one(obs.dict())
|
|
|
|
# Uppdatera objektets senaste tillstånd
|
|
self._update_object_condition(obs.object_goid, obs.overall_condition)
|
|
|
|
return obs.id
|
|
|
|
def get_for_object(self, goid: str, limit: int = 100) -> List[Observation]:
|
|
"""Hämta alla observationer för ett objekt"""
|
|
cursor = self.db.observations.find(
|
|
{"object_goid": goid}
|
|
).sort("timestamp", -1).limit(limit)
|
|
|
|
return [Observation(**doc) for doc in cursor]
|
|
|
|
def get_trend(self, goid: str, months: int = 6) -> Dict:
|
|
"""Analysera trend för objekt"""
|
|
observations = self.get_for_object(goid, limit=1000)
|
|
|
|
# Gruppera per månad
|
|
monthly = {}
|
|
for obs in observations:
|
|
month_key = obs.timestamp.strftime("%Y-%m")
|
|
if month_key not in monthly:
|
|
monthly[month_key] = []
|
|
monthly[month_key].append(obs.overall_condition)
|
|
|
|
# Beräkna medel per månad
|
|
trend = {
|
|
month: sum(conditions) / len(conditions)
|
|
for month, conditions in monthly.items()
|
|
}
|
|
|
|
return {
|
|
"goid": goid,
|
|
"trend": trend,
|
|
"improving": trend[-1] < trend[0] if len(trend) > 1 else None,
|
|
"observation_count": len(observations)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Vecka 3-4: Fas 2 Struktur (Lager 3, 7, 4)
|
|
|
|
### Dag 8-10: Metadata (Lager 3)
|
|
|
|
**Uppgift:** Bygg objekt-metadata
|
|
|
|
```python
|
|
# object_metadata.py
|
|
from pydantic import BaseModel
|
|
from typing import List, Optional, Dict
|
|
from datetime import date
|
|
|
|
class Dimensions(BaseModel):
|
|
length: Optional[float] = None
|
|
width: Optional[float] = None
|
|
height: Optional[float] = None
|
|
diameter: Optional[float] = None
|
|
unit: str = "m"
|
|
|
|
class ObjectMetadata(BaseModel):
|
|
goid: str
|
|
object_type: str
|
|
material: List[str]
|
|
dimensions: Optional[Dimensions] = None
|
|
manufacturer: Optional[str] = None
|
|
manufacturing_year: Optional[int] = None
|
|
installation_date: Optional[date] = None
|
|
design_lifespan: Optional[int] = None # år
|
|
owner: Optional[str] = None # org:id
|
|
maintainer: Optional[str] = None # org:id
|
|
|
|
# Standarder
|
|
standard: Optional[str] = None
|
|
certification: Optional[str] = None
|
|
|
|
class Config:
|
|
schema_extra = {
|
|
"example": {
|
|
"goid": "BYG-FAC-WIN-GLA-2847",
|
|
"object_type": "window_glass",
|
|
"material": ["glass", "aluminum"],
|
|
"dimensions": {
|
|
"width": 2.1,
|
|
"height": 1.5,
|
|
"unit": "m"
|
|
},
|
|
"installation_date": "2020-03-15",
|
|
"owner": "org:ikea"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Dag 11-12: Felkoder (Lager 7)
|
|
|
|
**Uppgift:** Definiera felkoder för Fas 1
|
|
|
|
```python
|
|
# defect_codes.py
|
|
from enum import Enum
|
|
|
|
class DefectCode(str, Enum):
|
|
"""Felkoder för IOM — Fas 1 (kommersiellt fokus)"""
|
|
|
|
# 2000 — Ytskada
|
|
DIRT_ACCUMULATION = "2100" # Nedsmutsning
|
|
COLOR_CHANGE = "2200" # Färgförändring
|
|
SURFACE_DAMAGE = "2300" # Ytskada
|
|
GRAFFITI = "2400" # Klotter
|
|
|
|
# 3000 — Strukturell skada
|
|
CRACK = "3100" # Spricka
|
|
DEFORMATION = "3200" # Deformation
|
|
MATERIAL_LOSS = "3300" # Materialförlust
|
|
|
|
# 4000 — Saknad / Trasig komponent
|
|
MISSING_PART = "4100" # Saknad del
|
|
BROKEN_PART = "4200" # Trasig del
|
|
LOOSE_PART = "4300" # Lossnad del
|
|
|
|
# 5000 — Blockering
|
|
PHYSICAL_BLOCK = "5100" # Fysisk blockering
|
|
VISUAL_BLOCK = "5200" # Synlig blockering
|
|
|
|
# 6000 — Miljö
|
|
VEGETATION = "6100" # Vegetation
|
|
WATER_DAMAGE = "6200" # Vattenskada
|
|
ICE_DAMAGE = "6300" # Isskada
|
|
|
|
class DefectRegistry:
|
|
"""Register över felkoder med beskrivningar"""
|
|
|
|
CODES = {
|
|
"2100": {"sv": "Nedsmutsning", "en": "Dirt accumulation", "category": "surface"},
|
|
"2200": {"sv": "Färgförändring", "en": "Color change", "category": "surface"},
|
|
"2300": {"sv": "Ytskada", "en": "Surface damage", "category": "surface"},
|
|
"2400": {"sv": "Klotter", "en": "Graffiti", "category": "surface"},
|
|
"3100": {"sv": "Spricka", "en": "Crack", "category": "structural"},
|
|
"3200": {"sv": "Deformation", "en": "Deformation", "category": "structural"},
|
|
"3300": {"sv": "Materialförlust", "en": "Material loss", "category": "structural"},
|
|
"4100": {"sv": "Saknad del", "en": "Missing part", "category": "component"},
|
|
"4200": {"sv": "Trasig del", "en": "Broken part", "category": "component"},
|
|
"4300": {"sv": "Lossnad del", "en": "Loose part", "category": "component"},
|
|
"5100": {"sv": "Fysisk blockering", "en": "Physical blockage", "category": "blockage"},
|
|
"5200": {"sv": "Synlig blockering", "en": "Visual blockage", "category": "blockage"},
|
|
"6100": {"sv": "Vegetation", "en": "Vegetation", "category": "environmental"},
|
|
"6200": {"sv": "Vattenskada", "en": "Water damage", "category": "environmental"},
|
|
"6300": {"sv": "Isskada", "en": "Ice damage", "category": "environmental"},
|
|
}
|
|
|
|
@classmethod
|
|
def get_description(cls, code: str, lang: str = "sv") -> str:
|
|
"""Hämta beskrivning på angivet språk"""
|
|
if code in cls.CODES:
|
|
return cls.CODES[code].get(lang, cls.CODES[code]["en"])
|
|
return "Okänd felkod"
|
|
|
|
@classmethod
|
|
def get_category(cls, code: str) -> str:
|
|
"""Hämta kategori"""
|
|
return cls.CODES.get(code, {}).get("category", "unknown")
|
|
```
|
|
|
|
---
|
|
|
|
### Dag 13-14: BOM (Lager 4, förenklat)
|
|
|
|
**Uppgift:** Komponentstruktur (1 nivå)
|
|
|
|
```python
|
|
# bom_structure.py
|
|
from pydantic import BaseModel
|
|
from typing import List, Optional
|
|
|
|
class Component(BaseModel):
|
|
goid: str
|
|
name: str
|
|
quantity: int = 1
|
|
unit: str = "st"
|
|
|
|
# Livscykel
|
|
installation_date: Optional[str] = None
|
|
expected_lifespan: Optional[int] = None # år
|
|
|
|
# Status
|
|
status: str = "active" # active, retired, replaced
|
|
|
|
class BOM(BaseModel):
|
|
"""Bill of Materials för infrastrukturobjekt"""
|
|
|
|
parent_goid: str
|
|
parent_name: str
|
|
components: List[Component]
|
|
|
|
def get_active_components(self) -> List[Component]:
|
|
"""Hämta aktiva komponenter"""
|
|
return [c for c in self.components if c.status == "active"]
|
|
|
|
def get_component_by_type(self, component_type: str) -> List[Component]:
|
|
"""Hämta komponenter av specifik typ"""
|
|
return [
|
|
c for c in self.components
|
|
if c.goid.split('-')[-2] == component_type
|
|
]
|
|
|
|
# Exempel: Gatlykta
|
|
street_light_bom = BOM(
|
|
parent_goid="BEL-STR-LED-0001",
|
|
parent_name="Gatlykta Drottningholm",
|
|
components=[
|
|
Component(goid="BEL-STR-FND-CON-0001", name="Fundament", quantity=1),
|
|
Component(goid="BEL-STR-BLT-GAL-0001", name="Förankringsbultar M24", quantity=4),
|
|
Component(goid="BEL-STR-POL-GAL-0001", name="Stolpe 6m", quantity=1),
|
|
Component(goid="BEL-STR-ARM-LED-0001", name="LED-armatur", quantity=1),
|
|
Component(goid="BEL-STR-DRV-LED-0001", name="Drivdon", quantity=1),
|
|
]
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## Vecka 5-6: Fas 3 Intelligens (Lager 8, 9)
|
|
|
|
### Dag 15-17: Riskmodell (Lager 8)
|
|
|
|
**Uppgift:** Riskberäkning
|
|
|
|
```python
|
|
# risk_model.py
|
|
from typing import Dict
|
|
from pydantic import BaseModel
|
|
|
|
class RiskScores(BaseModel):
|
|
safety: int = 0 # 0-10
|
|
economic: int = 0 # 0-10
|
|
operational: int = 0 # 0-10
|
|
legal: int = 0 # 0-10
|
|
environmental: int = 0 # 0-10
|
|
aesthetic: int = 0 # 0-10
|
|
|
|
class RiskWeights:
|
|
"""Vikter per objekttyp"""
|
|
|
|
DEFAULT = {
|
|
"safety": 0.3,
|
|
"economic": 0.2,
|
|
"operational": 0.2,
|
|
"legal": 0.1,
|
|
"environmental": 0.1,
|
|
"aesthetic": 0.1
|
|
}
|
|
|
|
BRIDGE = {
|
|
"safety": 0.4,
|
|
"economic": 0.2,
|
|
"operational": 0.2,
|
|
"legal": 0.1,
|
|
"environmental": 0.05,
|
|
"aesthetic": 0.05
|
|
}
|
|
|
|
WINDOW = {
|
|
"safety": 0.1,
|
|
"economic": 0.2,
|
|
"operational": 0.1,
|
|
"legal": 0.1,
|
|
"environmental": 0.1,
|
|
"aesthetic": 0.4
|
|
}
|
|
|
|
def calculate_risk(scores: RiskScores, object_type: str = "default") -> Dict:
|
|
"""Beräkna sammanlagd risk"""
|
|
|
|
weights = getattr(RiskWeights, object_type.upper(), RiskWeights.DEFAULT)
|
|
|
|
total = sum(
|
|
getattr(scores, dim) * weight
|
|
for dim, weight in weights.items()
|
|
)
|
|
|
|
return {
|
|
"total": round(min(10, max(0, total)), 2),
|
|
"breakdown": scores.dict(),
|
|
"weights": weights,
|
|
"level": _risk_level(total)
|
|
}
|
|
|
|
def _risk_level(score: float) -> str:
|
|
if score >= 8: return "critical"
|
|
if score >= 6: return "high"
|
|
if score >= 4: return "medium"
|
|
if score >= 2: return "low"
|
|
return "minimal"
|
|
```
|
|
|
|
---
|
|
|
|
### Dag 18-21: Relationer (Lager 9, förenklat)
|
|
|
|
**Uppgift:** Enkla relationer
|
|
|
|
```python
|
|
# relations.py
|
|
from typing import List, Dict
|
|
from pydantic import BaseModel
|
|
|
|
class Relation(BaseModel):
|
|
type: str # part_of, owned_by, adjacent_to
|
|
target_goid: str
|
|
target_name: Optional[str] = None
|
|
|
|
class ObjectGraph:
|
|
"""Enkel kunskapsgraf för IOM"""
|
|
|
|
def __init__(self):
|
|
self.relations: Dict[str, List[Relation]] = {}
|
|
|
|
def add_relation(self, from_goid: str, relation: Relation):
|
|
"""Lägg till relation"""
|
|
if from_goid not in self.relations:
|
|
self.relations[from_goid] = []
|
|
self.relations[from_goid].append(relation)
|
|
|
|
def get_related(self, goid: str, relation_type: Optional[str] = None) -> List[Relation]:
|
|
"""Hämta relaterade objekt"""
|
|
relations = self.relations.get(goid, [])
|
|
|
|
if relation_type:
|
|
relations = [r for r in relations if r.type == relation_type]
|
|
|
|
return relations
|
|
|
|
def get_owners(self, goid: str) -> List[str]:
|
|
"""Hämta ägare för objekt"""
|
|
owners = []
|
|
for from_goid, relations in self.relations.items():
|
|
for rel in relations:
|
|
if rel.target_goid == goid and rel.type == "owned_by":
|
|
owners.append(from_goid)
|
|
return owners
|
|
```
|
|
|
|
---
|
|
|
|
## Vecka 7-10: Fas 4 Vision (Lager 10)
|
|
|
|
### Dag 22-30: Digital tvilling
|
|
|
|
**Uppgift:** Dashboard och API
|
|
|
|
```python
|
|
# digital_twin_api.py
|
|
from fastapi import FastAPI, HTTPException
|
|
from typing import List, Optional
|
|
import asyncio
|
|
|
|
app = FastAPI(title="IOM Digital Twin API")
|
|
|
|
@app.get("/objects/{goid}")
|
|
async def get_object(goid: str):
|
|
"""Hämta komplett objekt med historik"""
|
|
obj = await db.objects.find_one({"goid": goid})
|
|
if not obj:
|
|
raise HTTPException(status_code=404, detail="Object not found")
|
|
|
|
# Hämta observationer
|
|
observations = await db.observations.find(
|
|
{"object_goid": goid}
|
|
).sort("timestamp", -1).to_list(100)
|
|
|
|
# Hämta relationer
|
|
relations = await db.relations.find(
|
|
{"from_goid": goid}
|
|
).to_list(100)
|
|
|
|
return {
|
|
"object": obj,
|
|
"observations": observations,
|
|
"relations": relations,
|
|
"latest_condition": observations[0]["overall_condition"] if observations else None,
|
|
"observation_count": len(observations)
|
|
}
|
|
|
|
@app.get("/objects/{goid}/timeline")
|
|
async def get_timeline(goid: str, months: int = 12):
|
|
"""Hämta tidslinje för objekt"""
|
|
observations = await db.observations.find(
|
|
{"object_goid": goid}
|
|
).sort("timestamp", 1).to_list(1000)
|
|
|
|
timeline = []
|
|
for obs in observations:
|
|
timeline.append({
|
|
"date": obs["timestamp"],
|
|
"condition": obs.get("overall_condition"),
|
|
"findings": [f["type"] for f in obs.get("findings", [])],
|
|
"risk_level": obs.get("risk_level"),
|
|
"media_count": len(obs.get("media", []))
|
|
})
|
|
|
|
return {"goid": goid, "timeline": timeline}
|
|
|
|
@app.get("/queries/condition-degradation")
|
|
async def find_degrading_objects(
|
|
domain: Optional[str] = None,
|
|
min_observations: int = 2,
|
|
threshold: float = 1.0
|
|
):
|
|
"""Hitta objekt som försämrats över tid"""
|
|
|
|
pipeline = [
|
|
{"$match": {"object_goid": {"$regex": f"^{domain}"}} if domain else {}},
|
|
{"$group": {
|
|
"_id": "$object_goid",
|
|
"first_condition": {"$first": "$overall_condition"},
|
|
"last_condition": {"$last": "$overall_condition"},
|
|
"count": {"$sum": 1}
|
|
}},
|
|
{"$match": {
|
|
"count": {"$gte": min_observations},
|
|
"$expr": {"$gte": [
|
|
{"$subtract": ["$first_condition", "$last_condition"]},
|
|
threshold
|
|
]}
|
|
}}
|
|
]
|
|
|
|
results = await db.observations.aggregate(pipeline).to_list(100)
|
|
return results
|
|
```
|
|
|
|
---
|
|
|
|
## Teknisk stack
|
|
|
|
| Komponent | Teknik |
|
|
|---|---|
|
|
| API | FastAPI (Python) |
|
|
| Databas | PostgreSQL + PostGIS (geodata) |
|
|
| Cache | Redis |
|
|
| Bildlagring | S3 |
|
|
| AI-integration | REST API till befintlig AI-tjänst |
|
|
| Dokumentation | OpenAPI / Swagger |
|
|
|
|
---
|
|
|
|
## Milestones
|
|
|
|
| Vecka | Milestone | Kriterier |
|
|
|---|---|---|
|
|
| 2 | Taxonomi + GOID | Kan skapa och validera objekt-ID |
|
|
| 4 | Observationer | Kan spara och hämta observationer med bilder |
|
|
| 6 | Metadata + Felkoder | Kan klassificera och söka på felkoder |
|
|
| 8 | Risk + Relationer | Kan beräkna risk och följa relationer |
|
|
| 10 | Digital tvilling | Dashboard med tidslinje och trender |
|
|
|
|
---
|
|
|
|
## Nästa steg
|
|
|
|
1. **Dag 1:** Sätt upp repo och CI/CD
|
|
2. **Dag 2:** Implementera taxonomi
|
|
3. **Dag 3:** Implementera GOID-generator
|
|
4. **Dag 4:** Sätt upp databas
|
|
5. **Dag 5:** Implementera observation-API
|
|
|
|
**Total tid till MVP:** 2 veckor
|
|
**Total tid till full IOM:** 10 veckor
|