Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CONTRIBUTING.rst
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ Navigate into the location where you cloned *Parasect* and install the package w
.. code:: console

$ poetry install
$ poetry self add poetry-plugin-shell
$ poetry self add poetry-plugin-export

You can now run an interactive Poetry shell, giving you access to the virtual environment.

Expand Down
5 changes: 3 additions & 2 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "parasect"
version = "1.2.0"
version = "1.2.1-dev"
description = "Utility for manipulating parameter sets for autopilots."
authors = ["George Zogopoulos <geo.zogop.papal@gmail.com>"]
license = "MIT"
Expand Down
108 changes: 58 additions & 50 deletions src/parasect/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ class Formats(Enum):
"""Ardupilot-compatible file."""
apj = "apj"
"""File compatible with Ardupilot's apj tool."""
mp = "mp"
"""Ardupilot Mission Planner comma separated file with comments."""


ReservedOptions = Literal[
Expand Down Expand Up @@ -821,20 +823,6 @@ def build_param_from_qgc(row: list[str]) -> Parameter:
return param


def build_param_from_ulog_params(row: list[str]) -> Parameter:
"""Build a Parameter from an ulog_params printout entry."""
param_name = row[0]
param_value: int | float
try:
param_value = int(row[1])
except ValueError:
param_value = float(row[1])

param = Parameter(param_name, param_value)

return param


def build_param_from_mavproxy(item: Sequence) -> Parameter:
"""Convert a mavproxy parameter line to a parameter.

Expand Down Expand Up @@ -951,45 +939,12 @@ def read_params_qgc(filepath: Path) -> ParameterList:
raise (SyntaxError("Could not extract any parameter from file."))

return param_list
except UnicodeDecodeError as e:
raise SyntaxError(f"File encoding error - not a valid QGC parameter file: {e}") from e
except SyntaxError as e:
raise SyntaxError(f"File is not of QGC format:\n{e}") from e


@parser
def read_params_ulog_param(filepath: Path) -> ParameterList:
"""Read and parse the outputs of the ulog_params program."""
param_list = ParameterList()

try:
with open(filepath) as csvfile:
param_reader = csv.reader(csvfile, delimiter=",")
for param_row in param_reader: # pragma: no branch
if param_row[0][0] == "#": # Skip comment lines
continue
# Check if line has exactly two elements
if len(param_row) != 2:
raise SyntaxError(
f"Invalid number of elements for ulog param decoder: {len(param_row)}"
)
# Check if first element is a string
try:
float(param_row[0])
raise SyntaxError(
"First row element must be a parameter name string"
)
except ValueError:
pass
param = build_param_from_ulog_params(param_row)
param_list.add_param(param)

if len(param_list.params) == 0:
raise (SyntaxError("Could not extract any parameter from file."))

return param_list
except SyntaxError as e:
raise SyntaxError(f"File is not of ulog format:\n{e}") from e


def split_mavproxy_row(row: str) -> Sequence:
"""Split a line, assuming it is mavproxy syntax."""
params = row.split()
Expand All @@ -1015,13 +970,41 @@ def split_mavproxy_row(row: str) -> Sequence:
return params


def split_missionplanner_row(row: str) -> Sequence:
"""Split a line, assuming it is MissionPlanner or ULOG (as its subset) syntax."""
params = row.split(",", 1)
# Check if there's at least one comma (should have 2 parts)
if len(params) < 2:
raise SyntaxError("MP: Line must contain at least one comma separator.")

# Check if first element is a string
try:
float(params[0])
raise SyntaxError("MP: First row element must be a parameter name string.")
except ValueError:
pass

value_reasoning = params[1].split("#", 1)
params[1] = value_reasoning[0].strip()
try:
float(params[1])
except ValueError as e:
raise SyntaxError("MP: First row element must be a parameter name string.") from e
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This text is wrong. It should refer to the second element, the parameter value.

if len(value_reasoning) <= 1:
params.append("")
else:
params.append(value_reasoning[1].strip())

return params


@parser
def read_params_mavproxy(filepath: Path) -> ParameterList:
"""Read and parse the outputs of mavproxy."""
param_list = ParameterList()

try:
with open(filepath) as f:
with open(filepath, encoding='utf-8') as f:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious why this was necessary.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for supporting Cyrillic characters in the comments -)

for line in f: # pragma: no branch
if line[0] == "#": # Skip comment lines
continue
Expand All @@ -1037,6 +1020,31 @@ def read_params_mavproxy(filepath: Path) -> ParameterList:
raise SyntaxError(f"File is not of mavproxy format:\n{e}") from e


@parser
def read_params_missionplanner(filepath: Path) -> ParameterList:
"""Read and parse the outputs of MissionPlanner or ULOG (as its subset)."""
param_list = ParameterList()

try:
with open(filepath, encoding='utf-8') as f:
for line in f: # pragma: no branch
if line[0] == "#": # Skip comment lines
continue
# skip empty lines
if line.strip() == "":
continue
params = split_missionplanner_row(line)
param = build_param_from_mavproxy(params)
param_list.add_param(param)

if len(param_list.params) == 0:
raise (SyntaxError("Could not extract any parameter from file."))

return param_list
except SyntaxError as e:
raise SyntaxError(f"File is not of MissionPlanner format:\n{e}") from e


def read_params(filepath: Path) -> ParameterList:
"""Universal parameter reader."""
get_logger().debug(f"Attempting to read file {filepath}")
Expand Down
36 changes: 26 additions & 10 deletions src/parasect/build_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -642,30 +642,42 @@ def export_to_csv(self) -> Generator[str, None, None]:
yield from self.retrieve_header_footer("footer", Formats.csv)

def export_to_apm(
self, include_readonly: bool = False
self, include_readonly: bool = False, csv: bool = False, comments: bool = False
) -> Generator[str, None, None]:
"""Export as apm parameter file.

INPUTS:
include_readonly: flag to enable including @READONLY on a parameter.
Necessary for apj tools, unsuitable for loading via a GCS.
csv: flag to use comma-separated format instead of tab-separated.
comments: flag to add parameter descriptions as comments after #.
"""
# Read header
yield from self.retrieve_header_footer("header", Formats.apm)

indentation = ""
separator = "," if csv else "\t"

param_hashes = sorted(self.param_list.keys())
for param_name in param_hashes:
param_name = self.param_list[param_name].name
param_value = self.param_list[param_name].get_pretty_value()
is_readonly = self.param_list[param_name].readonly
for param_hash in param_hashes:
param = self.param_list[param_hash]
param_name = param.name
param_value = param.get_pretty_value()
is_readonly = param.readonly

# Build the parameter line
line = f"{indentation}{param_name}{separator}{param_value}"

# Add readonly marker if needed
if include_readonly and is_readonly:
readonly_string = "\t@READONLY"
else:
readonly_string = ""

yield f"{indentation}{param_name}\t{param_value}{readonly_string}\n"
readonly_string = f"{separator}@READONLY" if csv else "\t@READONLY"
line += readonly_string

# Add comment if requested and description is available
if comments and getattr(param, 'reasoning', None):
line += f" # {param.reasoning}"

yield line + "\n"

# Read footer
yield from self.retrieve_header_footer("footer", Formats.apm)
Expand Down Expand Up @@ -694,6 +706,8 @@ def export(self, format: Formats) -> Iterable[str]:
return self.export_to_apm(include_readonly=False)
elif format == Formats.apj:
return self.export_to_apm(include_readonly=True)
elif format == Formats.mp:
return self.export_to_apm(include_readonly=False, csv=True, comments=True)
else:
raise ValueError(f"Output format {format} not supported.")

Expand Down Expand Up @@ -734,6 +748,8 @@ def build_filename(format: Formats, meal: Meal) -> str:
filename += ".hil"
return filename
elif format in (Formats.apm, Formats.apj):
return f"{meal.name}.parm"
elif format == Formats.mp:
Comment on lines +751 to +752
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're re-routing the .param format to the Mission Planner one, and throwing .parm to .apm and .apj.
Can you explain this change a bit?

Copy link
Copy Markdown
Author

@flyasky flyasky Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both apj and mavproxy(apm) use the format key<tab>value (PARM)
This format does not allow comments and empty lines. Kept this part as is.

Mission planner (MP) exports parameters in the format key<comma>value (PARAM).
Also MP is able to read format key<comma>value with any number of empty lines, comments at the end of line, and full-line comments.

Basically I replaced/extended ulog type to more flexible MP format. And made it possible to open for comparison.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://ardupilot.org/dev/docs/apjtools-intro.html
maybe apj format should be updated separately, as it supports 4 elements in the row (including @readonly flag).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll try to disentangle the definitions of the different extensions, but keep in mind that, AFAIK, there isn't a hard rule for a 1:1 match between extension name and file format.

For example, the ArduPilot apj tool can probably handle .parm and .param files. I haven't tried it and I probably should.

return f"{meal.name}.param"
else:
raise ValueError(f"Unsupported format {format}")
Expand Down
19 changes: 19 additions & 0 deletions tests/assets/ardupilot/mission_planner.param
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#ACRO_YAW_RATE,90

AHRS_ORIENTATION,25 # realflight


### Variant 1 - GPS module + Spoofing
AHRS_GPS_USE,1
GPS1_TYPE,1


### Variant 2 - LUA Script
#SCR_ENABLE,1
AHRS_ORIG_LAT,37.090662
#AHRS_ORIG_LON,-3.0745569
#AHRS_ORIG_ALT,2736


AHRS_OPTIONS,3 # вимкнути DCM fallback in VTOL and FW
#AHRS_OPTIONS,7 # don't disable AIRSPEED using EKF біт3 (в симуляторі результати гірше)
Loading
Loading