PFCSchemas 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,52 @@
1
+ name: Publish to PyPI and create GH release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v[0-9]+.[0-9]+.[0-9]+'
7
+ jobs:
8
+ build:
9
+ name: Build distribution
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - name: "Set up Python"
16
+ uses: actions/setup-python@v4
17
+ with:
18
+ python-version-file: "pyproject.toml"
19
+
20
+ - name: Install dependencies
21
+ run: pip install .
22
+
23
+ - name: Install pypa / build
24
+ run: python3 -m pip install build --user
25
+
26
+ - name: Build a binary wheel and a source tarball
27
+ run: python3 -m build
28
+
29
+ - name: Store the distribution packages
30
+ uses: actions/upload-artifact@v4
31
+ with:
32
+ name: python-package-distributions
33
+ path: dist/
34
+
35
+ publish-to-pypi:
36
+ name: Publish Python distribution to PyPI
37
+ needs: build
38
+ runs-on: ubuntu-latest
39
+ environment:
40
+ name: pypi
41
+ url: https://pypi.org/p/PFCSchemas
42
+ permissions:
43
+ id-token: write # IMPORTANT: mandatory for trusted publishing
44
+
45
+ steps:
46
+ - name: Download all the dists
47
+ uses: actions/download-artifact@v4
48
+ with:
49
+ name: python-package-distributions
50
+ path: dist/
51
+ - name: Publish distribution to PyPI
52
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,7 @@
1
+ {
2
+ "python.testing.pytestArgs": [
3
+ "tests"
4
+ ],
5
+ "python.testing.unittestEnabled": false,
6
+ "python.testing.pytestEnabled": true
7
+ }
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.3
2
+ Name: PFCSchemas
3
+ Version: 0.1.1
4
+ Summary: Pydantic schemas for the pyflightcoach libraries
5
+ Author-email: Thomas David <thomasdavid0@gmail.com>
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: numpy>=2.1.3
8
+ Requires-Dist: pandas>=2.2.3
9
+ Requires-Dist: pydantic>=2.9.2
10
+ Description-Content-Type: text/markdown
11
+
12
+ # PFCSchemas
13
+
14
+ Pydantic schemas for the pyflightcoach libraries
@@ -0,0 +1,3 @@
1
+ # PFCSchemas
2
+
3
+ Pydantic schemas for the pyflightcoach libraries
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "PFCSchemas"
3
+ description = "Pydantic schemas for the pyflightcoach libraries"
4
+ readme = "README.md"
5
+ authors = [{ name = "Thomas David", email = "thomasdavid0@gmail.com" }]
6
+ requires-python = ">=3.12"
7
+ dependencies = ["numpy>=2.1.3", "pandas>=2.2.3", "pydantic>=2.9.2"]
8
+ dynamic = ["version"]
9
+
10
+ [build-system]
11
+ requires = ["hatchling", "hatch-vcs"]
12
+ build-backend = "hatchling.build"
13
+
14
+ [tool.hatch.version]
15
+ source = "vcs"
16
+
17
+ [dependency-groups]
18
+ dev = ["pytest>=8.3.3"]
19
+ lint = ["ruff>=0.7.3"]
@@ -0,0 +1,2 @@
1
+ def hello() -> str:
2
+ return "Hello from pfcschemas!"
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+ from pydantic import BaseModel
3
+ from pfcschemas import fcj
4
+ import pandas as pd
5
+ from datetime import datetime
6
+ from pfcschemas.utils.files import validate_json
7
+ from pfcschemas.ma import MA
8
+ from pfcschemas.sinfo import ScheduleInfo
9
+
10
+
11
+ class AJson(BaseModel):
12
+ origin: fcj.Origin | None = None
13
+ isComp: bool
14
+ sourceBin: str | None = None
15
+ sourceFCJ: str | None = None
16
+ bootTime: datetime | None = None
17
+ mans: list[MA]
18
+
19
+ def schedule(self):
20
+ schedules = [man.schedule for man in self.mans]
21
+ if all([s == schedules[0] for s in schedules[1:]]):
22
+ return schedules[0].fcj_to_pfc()
23
+ else:
24
+ return ScheduleInfo.mixed()
25
+
26
+ def all_versions(self):
27
+ versions = set()
28
+ for man in self.mans:
29
+ versions |= set(man.history.keys())
30
+ return list(versions)
31
+
32
+ def get_scores(self, version: str, props: fcj.ScoreProperties=None, group="total"):
33
+ if props is None:
34
+ props = fcj.ScoreProperties()
35
+ scores = {}
36
+ for man in self.mans:
37
+ if version in man.history:
38
+ scores[man.name] = man.history[version].get_score(props).__dict__[group]
39
+ return pd.Series(scores, name=version)
40
+
41
+ def create_score_df(
42
+ self, props: fcj.ScoreProperties, group="total", version: str = "All"
43
+ ):
44
+ versions = self.all_versions() if version == "All" else [version]
45
+ return pd.concat([self.get_scores(ver, props, group) for ver in versions], axis=1)
46
+
47
+ def check_version(self, version: str):
48
+ version = version[1:] if version.startswith('v') else version
49
+ return all([man.history is not None and version in man.history.keys() for man in self.mans])
50
+
51
+ @staticmethod
52
+ def parse_json(json: dict|str):
53
+ return AJson.model_validate(validate_json(json))
@@ -0,0 +1,196 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import re
5
+
6
+ import pandas as pd
7
+ from pydantic import BaseModel
8
+ from typing import Annotated
9
+
10
+
11
+ class FCJ(BaseModel):
12
+ version: str
13
+ comments: str
14
+ name: str
15
+ view: View
16
+ parameters: Parameters
17
+ scored: bool
18
+ scores: list[float]
19
+ human_scores: list[HumanResult] = []
20
+ fcs_scores: list[Result] = []
21
+ mans: list[Man]
22
+ data: list[Data]
23
+ jhash: int | None = None
24
+
25
+ def score_df(self):
26
+ return pd.concat(
27
+ {fcjr.fa_version: fcjr.to_df() for fcjr in self.fcs_scores},
28
+ axis=0,
29
+ names=["version", "manoeuvre", "difficulty", "truncate"],
30
+ )
31
+
32
+ def man_df(self):
33
+ return pd.DataFrame(
34
+ [man.__dict__ for man in self.mans[1:-1]],
35
+ index=pd.Index(range(len(self.mans[1:-1])), name="manoeuvre"),
36
+ )
37
+
38
+ def pfc_version_df(self):
39
+ sdf = self.score_df().loc[pd.IndexSlice[:, :, 3, False]]
40
+ return pd.concat(
41
+ [sdf, sdf.mul(self.man_df().k, axis=0)], axis=1, keys=["raw", "kfac"]
42
+ )
43
+
44
+ def version_summary_df(self):
45
+ return self.pfc_version_df().groupby("version").kfac.sum()
46
+
47
+ def latest_version(self):
48
+ return max([fcjr.fa_version for fcjr in self.fcs_scores])
49
+
50
+ @property
51
+ def id(self):
52
+ return re.search(r"\d{8}", self.name)[0]
53
+
54
+ @property
55
+ def created(self):
56
+ try:
57
+ return datetime.datetime.strptime(
58
+ re.search(r"_\d{2}_\d{2}_\d{2}_", self.name)[0], "_%y_%m_%d_"
59
+ )
60
+ except Exception:
61
+ return None
62
+
63
+
64
+ class View(BaseModel):
65
+ position: dict
66
+ target: dict
67
+
68
+
69
+ class Parameters(BaseModel):
70
+ rotation: float
71
+ start: int
72
+ stop: int
73
+ moveEast: float
74
+ moveNorth: float
75
+ wingspan: float
76
+ modelwingspan: float
77
+ elevate: float
78
+ originLat: float
79
+ originLng: float
80
+ originAlt: float
81
+ pilotLat: str | float
82
+ pilotLng: str | float
83
+ pilotAlt: str | float
84
+ centerLat: str | float
85
+ centerLng: str | float
86
+ centerAlt: str | float
87
+ schedule: list[str]
88
+
89
+
90
+ class HumanResult(BaseModel):
91
+ name: str
92
+ date: datetime.date
93
+ scores: list[float]
94
+
95
+
96
+ class Result(BaseModel):
97
+ fa_version: str
98
+ manresults: list[ManResult | None]
99
+
100
+ def to_df(self) -> pd.DataFrame:
101
+ return pd.concat(
102
+ {i: fcjmr.to_df() for i, fcjmr in enumerate(self.manresults[1:]) if fcjmr},
103
+ axis=0,
104
+ names=["manoeuvre", "difficulty", "truncate"],
105
+ )
106
+
107
+
108
+ class ManResult(BaseModel):
109
+ els: list[El]
110
+ results: list[Score] = []
111
+
112
+ def to_df(self) -> pd.DataFrame:
113
+ return pd.DataFrame(
114
+ data=[res.score.__dict__ for res in self.results],
115
+ index=pd.MultiIndex.from_frame(
116
+ pd.DataFrame([res.properties.__dict__ for res in self.results])
117
+ ),
118
+ )
119
+
120
+ def get_score(self, props: ScoreProperties):
121
+ for r in self.results:
122
+ if r.properties == props:
123
+ return r.score
124
+
125
+
126
+ class El(BaseModel):
127
+ name: str
128
+ start: int
129
+ stop: int
130
+
131
+
132
+ class Score(BaseModel):
133
+ score: ScoreValues
134
+ properties: ScoreProperties
135
+
136
+
137
+ class ScoreValues(BaseModel):
138
+ intra: float
139
+ inter: float
140
+ positioning: float
141
+ total: float
142
+
143
+
144
+ class ScoreProperties(BaseModel):
145
+ difficulty: int = 3
146
+ truncate: bool = False
147
+
148
+ def __eq__(self, other):
149
+ if not isinstance(other, ScoreProperties):
150
+ return False
151
+ return self.difficulty == other.difficulty and self.truncate == other.truncate
152
+
153
+
154
+ class Man(BaseModel):
155
+ name: str
156
+ k: float
157
+ id: str
158
+ sp: int
159
+ wd: float
160
+ start: int
161
+ stop: int
162
+ sel: bool
163
+ background: str
164
+
165
+
166
+ class Data(BaseModel):
167
+ VN: float = None
168
+ VE: float = None
169
+ VD: float = None
170
+ dPD: float = None #
171
+ r: float
172
+ p: float
173
+ yw: float
174
+ N: float
175
+ E: float
176
+ D: float
177
+ time: int
178
+ roll: float
179
+ pitch: float
180
+ yaw: float
181
+
182
+
183
+ def get_scores(file: str) -> pd.DataFrame:
184
+ fcj = FCJ.model_validate_json(open(file, "r").read())
185
+ return fcj.pfc_version_df()
186
+
187
+
188
+ class Origin(BaseModel):
189
+ lat: Annotated[float, "latitude in degrees"]
190
+ lng: Annotated[float, "latitude in degrees"]
191
+ alt: Annotated[float, "height AMSL in meters"]
192
+ heading: Annotated[float, "heading (direction of box Y axis) in degrees from North"]
193
+ move_north: Annotated[float, "non-standard offset of box"]=0
194
+ move_east: Annotated[float, "non-standard offset of box"]=0
195
+ move_down: Annotated[float, "non-standard offset of box"]=0
196
+
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import pandas as pd
4
+ from pydantic import BaseModel
5
+
6
+ from pfcschemas import fcj
7
+ from pfcschemas.sinfo import ScheduleInfo
8
+ from pfcschemas.maninfo import ManInfo
9
+
10
+ class MDef(BaseModel):
11
+ info: ManInfo
12
+ mps: dict
13
+ eds: dict
14
+ box: dict
15
+
16
+
17
+ class MA(BaseModel):
18
+ name: str
19
+ id: int
20
+ schedule: ScheduleInfo
21
+ schedule_direction: str | None
22
+ flown: list[dict] | dict
23
+
24
+ history: dict[str, fcj.ManResult] | None = None
25
+
26
+ mdef: MDef | None = None
27
+ manoeuvre: dict | None = None
28
+ template: list[dict] | dict | None = None
29
+ corrected: dict | None = None
30
+ corrected_template: list[dict] | dict | None = None
31
+ scores: dict | None = None
32
+
33
+ def basic(self):
34
+ return MA(
35
+ name=self.name,
36
+ id=self.id,
37
+ schedule=self.schedule,
38
+ schedule_direction=self.schedule_direction,
39
+ flown=self.flown,
40
+ history=self.history,
41
+ )
42
+
43
+ def add_mdef(self, mdef: MDef) -> MA:
44
+ return MA(**(self.__dict__ | dict(mdef=mdef)))
45
+
46
+ def simplify_history(self):
47
+ vnames = [v[1:] if v.startswith("v") else v for v in self.history.keys()]
48
+ vnames_old = vnames[::-1]
49
+ vnids = [
50
+ len(vnames) - vnames_old.index(vn) - 1
51
+ for vn in list(pd.Series(vnames).unique())
52
+ ]
53
+
54
+ return MA(
55
+ **(
56
+ self.__dict__
57
+ | dict(
58
+ history={vnames[i]: list(self.history.values())[i] for i in vnids}
59
+ )
60
+ )
61
+ )
62
+
63
+
64
+ # vids = [vnames.rindex(vn) for vn in set(vnames)]
@@ -0,0 +1,53 @@
1
+ from .positioning import Position, BoxLocation
2
+ from typing import Tuple, Annotated
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class ManInfo(BaseModel):
7
+ name: str
8
+ short_name: str
9
+ k: float
10
+ position: Position
11
+ start: BoxLocation
12
+ end: BoxLocation
13
+ centre_points: Annotated[
14
+ list[int],
15
+ "points that should be centered, ids correspond to the previous element",
16
+ ] = []
17
+ centred_els: Annotated[
18
+ list[Tuple[int, float]], "element ids that should be centered"
19
+ ] = []
20
+
21
+ def to_dict(self):
22
+ return self.model_dump()
23
+
24
+ @staticmethod
25
+ def from_dict(data: dict):
26
+ return ManInfo.model_validate(data)
27
+
28
+
29
+ def maninfomaker(
30
+ name: str,
31
+ short_name: str,
32
+ k: float,
33
+ position: Position,
34
+ start: BoxLocation,
35
+ end: BoxLocation,
36
+ centre_points: Annotated[
37
+ list[int],
38
+ "points that should be centered, ids correspond to the previous element",
39
+ ] = None,
40
+ centred_els: Annotated[
41
+ list[Tuple[int, float]], "element ids that should be centered"
42
+ ] = None,
43
+ ):
44
+ return ManInfo(
45
+ name=name,
46
+ short_name=short_name,
47
+ k=k,
48
+ position=position,
49
+ start=start,
50
+ end=end,
51
+ centre_points=centre_points if centre_points is not None else [],
52
+ centred_els=centred_els if centred_els is not None else [],
53
+ )
@@ -0,0 +1,100 @@
1
+ import numpy as np
2
+ from pydantic import BaseModel
3
+ from typing import Annotated
4
+ from .utils.enum import EnumStr
5
+
6
+
7
+ class Orientation(EnumStr):
8
+ UPRIGHT = np.pi
9
+ INVERTED = 0
10
+
11
+
12
+ class Heading(EnumStr):
13
+ RTOL = np.pi
14
+ LTOR = 0
15
+ OUTTOIN = 3 * np.pi / 2
16
+ INTOOUT = np.pi / 2
17
+
18
+ @staticmethod
19
+ def values():
20
+ return np.array(list(Heading.__members__.values()))
21
+
22
+ @staticmethod
23
+ def infer(bearing: Annotated[float, "in radians from north"]):
24
+ def check(bearing: float, heading: Heading):
25
+ return (
26
+ np.round(np.abs(4 * (bearing - heading.value)) / (2 * np.pi)).astype(
27
+ int
28
+ )
29
+ % 4
30
+ ) == 0
31
+
32
+ for head in Heading.__members__.values():
33
+ if check(bearing, head):
34
+ return head
35
+ else:
36
+ raise ValueError(f"Invalid bearing {bearing}")
37
+
38
+ def reverse(self):
39
+ return {
40
+ Heading.RTOL: Heading.LTOR,
41
+ Heading.LTOR: Heading.RTOL,
42
+ Heading.OUTTOIN: Heading.INTOOUT,
43
+ Heading.INTOOUT: Heading.OUTTOIN,
44
+ }[self]
45
+
46
+
47
+ class Direction(EnumStr):
48
+ UPWIND = 1
49
+ DOWNWIND = -1
50
+ CROSS = 0
51
+
52
+ def wind_swap_heading(self, d_or_w: Heading) -> int:
53
+ match self:
54
+ case Direction.UPWIND:
55
+ return d_or_w
56
+ case Direction.DOWNWIND:
57
+ return d_or_w.reverse()
58
+ case Direction.CROSS:
59
+ return d_or_w
60
+
61
+ @staticmethod
62
+ def parse(s: str):
63
+ match s[0].lower():
64
+ case "u":
65
+ return Direction.UPWIND
66
+ case "d":
67
+ return Direction.DOWNWIND
68
+ case "c":
69
+ return Direction.CROSS
70
+ case _:
71
+ raise ValueError(f"Invalid wind {s}")
72
+
73
+
74
+ class Height(EnumStr):
75
+ BTM = 0.2
76
+ MID = 0.6
77
+ TOP = 1.0
78
+
79
+
80
+ class Position(EnumStr):
81
+ CENTRE = 0
82
+ END = 1
83
+
84
+
85
+ class BoxLocation(BaseModel):
86
+ height: Height
87
+ direction: Direction | None = None
88
+ orientation: Orientation | None = None
89
+
90
+
91
+ def boxlocationmaker(
92
+ height: Height,
93
+ direction: Direction | None = None,
94
+ orientation: Orientation | None = None,
95
+ ):
96
+ return BoxLocation(
97
+ height=height,
98
+ direction=direction,
99
+ orientation=orientation,
100
+ )
File without changes
@@ -0,0 +1,31 @@
1
+ from flightanalysis import SchedDef
2
+ from pydantic import BaseModel
3
+
4
+ from pfcschemas.sinfo import ScheduleInfo
5
+
6
+
7
+ class DirectionDefinition(BaseModel):
8
+ manid: int
9
+ direction: str
10
+
11
+ @staticmethod
12
+ def from_sdef(sdef: SchedDef):
13
+ manid = sdef.wind_def_manoeuvre()
14
+ return DirectionDefinition(
15
+ manid=manid, direction=sdef[manid].info.start.direction.name
16
+ )
17
+
18
+
19
+ class SDefFile(BaseModel):
20
+ category: str
21
+ schedule: str
22
+ direction_definition: DirectionDefinition
23
+ fa_version: str
24
+ mdefs: dict[str, dict | list[dict]]
25
+
26
+ @property
27
+ def sinfo(self):
28
+ return ScheduleInfo(self.category, self.schedule)
29
+
30
+ def create_definition(self):
31
+ return SchedDef.from_dict(self.mdefs)